Cómo construir navegación con clic para mover
Este tutorial fue escrito en febrero de 2026, para la v2 del motor.
La mayoría de los juegos necesitan una forma de decir "ve allí." Juegos de estrategia, RPGs, aventuras point-and-click — todos usan la misma idea. Haces clic en un punto, y el personaje camina hasta él, esquivando muros y obstáculos en el camino. Es uno de los sistemas de movimiento más comunes, y está construido sobre un algoritmo sorprendentemente simple.
Vamos a construir dos modos: movimiento por cuadrícula (el personaje salta de casilla en casilla) y movimiento libre (el personaje se desliza suavemente entre celdas). Ambos usan pathfinding BFS para esquivar obstáculos. El mapa completo es una cuadrícula de 16x16 casillas que llena la pantalla de 128x128 exactamente — no necesitamos cámara. Si quieres repasar los tilemaps, consulta el tutorial del sistema de mapas. Para la entrada del ratón, el tutorial de cursores personalizados es un buen punto de partida.
La cuadrícula
Todo juego top-down empieza con una cuadrícula. Cada celda es suelo o muro. Definimos dos sprites de 8x8 — verde oscuro para el suelo, gris oscuro para los muros — y usamos mset() para colocarlos en el tilemap. 16 casillas de 8 píxeles cada una llenan la pantalla de 128 píxeles exactamente.
El diseño de la cuadrícula es un array 2D de 0s y 1s. En init(), lo recorremos y llamamos a mset() para cada celda. map(0) renderiza todo. Vamos a configurarlo:
const sprites = {
floor: [[
3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 11, 3,
3, 3, 3, 3, 3, 3, 3, 3,
3, 11, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 11, 3, 3,
3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 3, 3,
], null],
wall: [[
5, 5, 5, 5, 5, 5, 5, 5,
5, 6, 5, 5, 5, 6, 5, 5,
5, 5, 5, 5, 5, 5, 5, 5,
5, 5, 5, 6, 5, 5, 5, 5,
5, 5, 5, 5, 5, 5, 5, 5,
5, 5, 6, 5, 5, 5, 5, 6,
5, 5, 5, 5, 5, 5, 5, 5,
5, 5, 5, 5, 5, 6, 5, 5,
], null],
};
// prettier-ignore
const grid = [
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
[1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1],
[1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1],
[1,0,0,0,0,0,1,0,0,1,1,1,0,0,0,1],
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
[1,1,1,0,1,1,1,1,1,0,1,1,1,1,0,1],
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
[1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,1],
[1,0,0,0,1,0,0,0,0,0,0,1,0,0,0,1],
[1,0,0,0,0,0,0,1,1,1,0,0,0,0,0,1],
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
[1,0,1,1,1,0,0,0,0,0,0,1,1,0,0,1],
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
];
let selX = -1, selY = -1;
function init() {
for (let y = 0; y < 16; y++)
for (let x = 0; x < 16; x++)
mset(x, y, grid[y][x] === 1 ? 'wall' : 'floor');
}
function update() {
if (click()) {
const pos = mouse();
selX = Math.max(0, Math.min(15, Math.floor(pos.x / 8)));
selY = Math.max(0, Math.min(15, Math.floor(pos.y / 8)));
}
}
function draw() {
cls(0);
map(0);
if (selX >= 0) {
rect(selX * 8, selY * 8, selX * 8 + 8, selY * 8 + 8, 10);
text(selX + ',' + selY, 1, 1, 7);
}
}
Math.floor(pos.x / 8) convierte una posición en píxeles a una coordenada de cuadrícula. Limitamos a 0–15 para que los clics en los bordes no se salgan del rango. El contorno amarillo resalta la celda seleccionada — más adelante, se convertirá en nuestro objetivo de movimiento.
Colocar al jugador
El jugador necesita una posición en la cuadrícula. La almacenamos como enteros — coordenadas de cuadrícula, no píxeles. Un sprite azul de personaje se sitúa sobre el tilemap, y un contorno amarillo marca dónde hicimos clic. Los clics en muros se rechazan, porque no tiene sentido intentar caminar hacia una roca sólida:
let playerX = 1, playerY = 1;
let targetX = -1, targetY = -1;
// inside update()
if (click()) {
const pos = mouse();
const gx = Math.max(0, Math.min(15, Math.floor(pos.x / 8)));
const gy = Math.max(0, Math.min(15, Math.floor(pos.y / 8)));
if (mget(gx, gy, 0) !== 'wall') {
targetX = gx;
targetY = gy;
}
}
// inside draw()
cls(0);
map(0);
if (targetX >= 0)
rect(targetX * 8, targetY * 8, targetX * 8 + 8, targetY * 8 + 8, 10);
spr('player', playerX * 8, playerY * 8);
spr('player', playerX * 8, playerY * 8) dibuja al jugador en la posición correcta en píxeles. mget(gx, gy, 0) lee la casilla en esas coordenadas — si es 'wall', la ignoramos. El marcador de objetivo es un contorno amarillo con rect() alrededor de la celda de destino.
Movimiento por cuadrícula
Ahora hagamos que el jugador se mueva. Cada 8 frames (~133ms), damos un paso hacia el objetivo. El enfoque básico elige el eje con mayor distancia y avanza una celda en esa dirección. Antes de dar el paso, verificamos si la celda siguiente es un muro:
let moveTimer = 0;
// inside update(), after click handling
if (targetX >= 0 && (playerX !== targetX || playerY !== targetY)) {
moveTimer++;
if (moveTimer >= 8) {
moveTimer = 0;
const dx = targetX - playerX;
const dy = targetY - playerY;
let nx = playerX, ny = playerY;
if (Math.abs(dx) >= Math.abs(dy)) nx += Math.sign(dx);
else ny += Math.sign(dy);
if (mget(nx, ny, 0) !== 'wall') {
playerX = nx;
playerY = ny;
}
}
}
Esto funciona bien en áreas abiertas. Pero intenta hacer clic al otro lado de un muro — el jugador camina directo hacia él y se detiene. El enfoque codicioso siempre se mueve directamente hacia el objetivo. No sabe cómo rodear obstáculos. Necesitamos pathfinding.
Pruébalo: Haz clic en una celda al otro lado del muro largo en la fila 6. El jugador camina directo hacia el muro y se detiene. Este es el problema que resuelve el pathfinding.
Pathfinding con BFS
La búsqueda en anchura (BFS) explora desde el inicio hacia afuera, una celda a la vez, hasta alcanzar el objetivo. Cada celda recuerda de cuál vino. Una vez que llegamos al objetivo, rastreamos esos padres hacia atrás para construir el camino más corto.
function findPath(sx, sy, gx, gy) {
if (mget(gx, gy, 0) === 'wall') return null;
const key = (x, y) => x + ',' + y;
const queue = [{ x: sx, y: sy }];
const visited = new Set([key(sx, sy)]);
const parent = {};
while (queue.length > 0) {
const { x, y } = queue.shift();
if (x === gx && y === gy) {
const path = [];
let cx = gx, cy = gy;
while (cx !== sx || cy !== sy) {
path.push({ x: cx, y: cy });
const p = parent[key(cx, cy)];
cx = p.x;
cy = p.y;
}
return path.reverse();
}
for (const [dx, dy] of [[0, -1], [1, 0], [0, 1], [-1, 0]]) {
const nx = x + dx, ny = y + dy;
if (nx < 0 || nx > 15 || ny < 0 || ny > 15) continue;
if (visited.has(key(nx, ny))) continue;
if (mget(nx, ny, 0) === 'wall') continue;
visited.add(key(nx, ny));
parent[key(nx, ny)] = { x, y };
queue.push({ x: nx, y: ny });
}
}
return null;
}
La función recibe una posición inicial y una final, y devuelve un array de pasos {x, y} — o null si el objetivo es inalcanzable. Usa mget() para verificar cada vecino, saltando los muros. El camino excluye el inicio e incluye el objetivo, así que el jugador camina desde su celda actual a través de cada paso en secuencia.
Vamos a conectarlo. Al hacer clic, ejecutamos findPath() y almacenamos el resultado. En update(), extraemos un paso cada 8 frames. En draw(), puntos naranjas muestran el camino restante:
El jugador ahora esquiva los muros. BFS garantiza el camino más corto, y en una cuadrícula de 16x16 se ejecuta en microsegundos — sin problemas de rendimiento.
Consejo: BFS explora 4 direcciones (arriba, abajo, izquierda, derecha). Para movimiento diagonal, amplía la lista de vecinos a 8 direcciones — pero añade verificaciones para evitar que el personaje se cuele por huecos diagonales entre muros.
Movimiento libre
El movimiento por cuadrícula se ve un poco rígido — el jugador se teletransporta de casilla en casilla. El movimiento libre desliza al jugador suavemente entre puntos de referencia. El pathfinding es idéntico (BFS en espacio de cuadrícula), pero en vez de saltar a cada celda, nos movemos hacia el centro de la celda a una velocidad fija en píxeles:
let playerPx = 12, playerPy = 12;
let path = [];
const speed = 1.5;
// inside update(), after click sets path via findPath()
if (path.length > 0) {
const tx = path[0].x * 8 + 4;
const ty = path[0].y * 8 + 4;
const dx = tx - playerPx;
const dy = ty - playerPy;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 1) {
playerPx = tx;
playerPy = ty;
path.shift();
} else {
playerPx += (dx / dist) * speed;
playerPy += (dy / dist) * speed;
}
}
// inside draw()
spr('player', Math.floor(playerPx) - 4, Math.floor(playerPy) - 4);
El camino sigue siendo un array de celdas de cuadrícula. Cada frame, calculamos la dirección desde el jugador hacia el centro de la siguiente celda (path[0].x * 8 + 4), la normalizamos y multiplicamos por la velocidad. Cuando estamos a 1 píxel de distancia, nos ajustamos al centro y avanzamos al siguiente paso. La posición del jugador ahora es un valor flotante en píxeles en vez de celdas enteras.
Juntando todo
Aquí están ambos modos en un solo ejemplo. Pulsa 1 para movimiento por cuadrícula, 2 para movimiento libre. El mismo pathfinding BFS alimenta ambos — la única diferencia es cómo el jugador sigue el camino. Un cursor de punto de mira, un marcador de objetivo pulsante y una etiqueta de modo completan el ejemplo:
let mode = 'snap';
// inside init()
cursor('crosshair');
// inside update()
if (btnp('1') && mode !== 'snap') {
mode = 'snap';
playerX = Math.floor(playerPx / 8);
playerY = Math.floor(playerPy / 8);
path = [];
targetX = -1;
}
if (btnp('2') && mode !== 'free') {
mode = 'free';
playerPx = playerX * 8 + 4;
playerPy = playerY * 8 + 4;
path = [];
targetX = -1;
}
// movement depends on mode
if (mode === 'snap') {
// grid-snap: pop path every 8 frames
} else {
// free: lerp toward path[0] each frame
}
// inside draw()
if (mode === 'snap')
spr('player', playerX * 8, playerY * 8);
else
spr('player', Math.floor(playerPx) - 4, Math.floor(playerPy) - 4);
text(mode === 'snap' ? 'SNAP [1]' : 'FREE [2]', 1, 1, 7);
Al cambiar de modo se sincroniza la posición — el modo cuadrícula redondea a la celda más cercana, el modo libre coloca al jugador en el centro de la celda. El camino se borra al cambiar para que no quede movimiento residual del modo anterior. Tómate un tiempo para editar el array del mapa y probar diferentes diseños de muros — pasillos, laberintos, arenas abiertas. El pathfinding se encarga de todo.
Para ir más allá
- Movimiento diagonal — amplía BFS a 8 vecinos para caminos diagonales, y añade verificaciones para evitar colarse por huecos diagonales entre muros
- Movimiento con pesos — usa Dijkstra o A* en vez de BFS para preferir ciertos terrenos (barro = lento, camino = rápido)
- Desplazamiento de cámara — combina con
camera()para mapas más grandes que la pantalla (consulta el tutorial del sistema de mapas) - Múltiples unidades — haz clic para seleccionar una unidad, luego clic para moverla, usando el mismo pathfinding por unidad
- Niebla de guerra — renderiza solo las casillas que el jugador ha visitado, usando una capa separada de "explorado"