Cómo construir un cursor virtual
Este tutorial fue escrito en febrero de 2026, para la v2 del motor.
Los juegos de estrategia, tower defense y menús estilo consola necesitan un cursor que se mueva con el teclado en lugar del ratón. El jugador navega un mundo — a veces más grande que la pantalla — usando botones, y "hace clic" en las cosas con una tecla de acción.
Vamos a construir un cursor virtual desde cero: un sprite que se mueve con las teclas de dirección, una cámara que desplaza un mundo más grande y detección de clics en objetos. Al final tendrás una pequeña demo interactiva donde seleccionas baldosas de colores en un mundo de 256x192. Si quieres ver estas ideas en un juego real, el playground de tower defense las usa todas. Para los conceptos básicos de cursor con ratón, consulta el tutorial de cursores personalizados.
Ocultar el cursor y definir un sprite
Igual que con un cursor de ratón personalizado — oculta el predeterminado del navegador con cursor(false) en init(). Para un cursor virtual, una cruceta funciona mejor que una flecha. El espacio central te permite ver lo que hay debajo, lo cual importa cuando estás seleccionando cosas:
const sprites = {
cursor: [
-1, -1, -1, 7, -1, -1, -1, -1,
-1, -1, -1, 7, -1, -1, -1, -1,
-1, -1, -1, -1, -1, -1, -1, -1,
7, 7, -1, -1, -1, 7, 7, -1,
-1, -1, -1, -1, -1, -1, -1, -1,
-1, -1, -1, 7, -1, -1, -1, -1,
-1, -1, -1, 7, -1, -1, -1, -1,
-1, -1, -1, -1, -1, -1, -1, -1,
],
};
// inside init()
cursor(false);
Mover el cursor con botones
Esta es la diferencia clave con un cursor de ratón: en lugar de leer mouse(), registramos la posición del cursor como variables y las actualizamos en cada frame con btn(). btn() devuelve true cada frame mientras la tecla está presionada, así que el movimiento se siente fluido. Limitamos a los bordes de la pantalla para mantener el cursor visible, y desplazamos el dibujo del sprite 3 píxeles (la mitad de 8 menos 1 por el espacio) para centrar la cruceta en la posición lógica:
engine.scope(({ start, cls, spr, btn, cursor }) => {
const sprites = {
cursor: [
-1, -1, -1, 7, -1, -1, -1, -1,
-1, -1, -1, 7, -1, -1, -1, -1,
-1, -1, -1, -1, -1, -1, -1, -1,
7, 7, -1, -1, -1, 7, 7, -1,
-1, -1, -1, -1, -1, -1, -1, -1,
-1, -1, -1, 7, -1, -1, -1, -1,
-1, -1, -1, 7, -1, -1, -1, -1,
-1, -1, -1, -1, -1, -1, -1, -1,
],
};
let cursorX = 64;
let cursorY = 64;
const speed = 2;
function init() {
cursor(false);
}
function update() {
if (btn('ArrowLeft')) {
cursorX -= speed;
}
if (btn('ArrowRight')) {
cursorX += speed;
}
if (btn('ArrowUp')) {
cursorY -= speed;
}
if (btn('ArrowDown')) {
cursorY += speed;
}
cursorX = Math.max(0, Math.min(127, cursorX));
cursorY = Math.max(0, Math.min(127, cursorY));
}
function draw() {
cls(1);
spr('cursor', cursorX - 3, cursorY - 3);
}
start({ sprites, sounds: {}, init, update, draw, target });
});
Añadir un mundo con cámara
Hasta ahora el cursor está limitado a la pantalla de 128x128. Para navegar un mundo más grande, usamos camera(x, y) — hace que todas las funciones de dibujo resten ese desplazamiento, así que solo aparece la porción visible en pantalla. La posición del cursor ahora rastrea coordenadas del mundo y puede exceder 0–127.
El patrón crítico es el dibujo en dos fases. Primero, establece camera() y dibuja todos los objetos del mundo. Luego restablece a camera(0, 0) y dibuja el cursor convirtiendo coordenadas del mundo a coordenadas de pantalla (resta el desplazamiento de la cámara):
const worldWidth = 256;
const worldHeight = 192;
let camX = 0;
let camY = 0;
// inside draw()
camera(camX, camY);
rectfill(0, 0, worldWidth, worldHeight, 3);
// ...draw world objects here...
camera(0, 0);
spr('cursor', cursorX - camX - 3, cursorY - camY - 3);
Esta distinción de coordenadas — mundo vs pantalla — es el concepto más importante. Usa coordenadas del mundo para la lógica del juego y detección de colisiones. Solo convierte a coordenadas de pantalla al dibujar superposiciones como el cursor.
Desplazar la cámara
Cuando la posición del cursor en pantalla se acerca a un borde, y hay más mundo por ver, la cámara debe seguir. Convertimos la posición del mundo a posición de pantalla con screenX = cursorX - camX, luego verificamos contra un umbral. Limitamos la cámara para que nunca muestre más allá de los bordes del mundo:
// inside update(), after moving the cursor
const threshold = 10;
const screenX = cursorX - camX;
const screenY = cursorY - camY;
if (screenX < threshold && camX > 0) {
camX -= speed;
}
if (screenX > 127 - threshold && camX < worldWidth - 128) {
camX += speed;
}
if (screenY < threshold && camY > 0) {
camY -= speed;
}
if (screenY > 127 - threshold && camY < worldHeight - 128) {
camY += speed;
}
Gestionar clics
Usamos btnp('z') o btnp('Enter') como botón de acción — btnp() devuelve true solo en el primer frame de una pulsación, así que obtienes una acción por toque. boxesCollide verifica si el cursor (como una caja pequeña de 2x2 en su centro) se superpone con una baldosa (caja de 16x16). Ambas posiciones están en coordenadas del mundo, así que la comparación funciona directamente — no necesita conversión de cámara:
const tiles = [
{ x: 40, y: 40, color: 8 },
{ x: 80, y: 60, color: 9 },
{ x: 160, y: 100, color: 11 },
{ x: 200, y: 140, color: 12 },
];
// inside update()
if (btnp('z') || btnp('Enter')) {
for (const tile of tiles) {
if (
boxesCollide(
[cursorX - 1, cursorY - 1, 2, 2],
[tile.x, tile.y, 16, 16],
)
) {
tile.color = tile.color === 7 ? 8 : 7;
}
}
}
Consejo: Siempre usa coordenadas del mundo para verificaciones de colisión. Solo convierte a coordenadas de pantalla al dibujar.
Todo junto
Aquí está todo combinado: un mundo de 256x192 con baldosas de colores, un cursor controlado por teclado, desplazamiento de cámara y detección de clics. Flechas para moverte, Z o Enter para alternar los colores de las baldosas:
engine.scope(
({ start, cls, spr, btn, btnp, cursor, camera, rectfill, boxesCollide }) => {
const sprites = {
cursor: [
-1, -1, -1, 7, -1, -1, -1, -1,
-1, -1, -1, 7, -1, -1, -1, -1,
-1, -1, -1, -1, -1, -1, -1, -1,
7, 7, -1, -1, -1, 7, 7, -1,
-1, -1, -1, -1, -1, -1, -1, -1,
-1, -1, -1, 7, -1, -1, -1, -1,
-1, -1, -1, 7, -1, -1, -1, -1,
-1, -1, -1, -1, -1, -1, -1, -1,
],
};
const worldW = 256,
worldH = 192,
speed = 2,
edge = 10;
let cx = 64,
cy = 64,
camX = 0,
camY = 0;
const tiles = [
{ x: 40, y: 40, color: 8 },
{ x: 80, y: 60, color: 9 },
{ x: 160, y: 100, color: 11 },
{ x: 200, y: 140, color: 12 },
];
function init() {
cursor(false);
}
function update() {
if (btn('ArrowLeft')) {
cx -= speed;
}
if (btn('ArrowRight')) {
cx += speed;
}
if (btn('ArrowUp')) {
cy -= speed;
}
if (btn('ArrowDown')) {
cy += speed;
}
cx = Math.max(0, Math.min(worldW, cx));
cy = Math.max(0, Math.min(worldH, cy));
const sx = cx - camX,
sy = cy - camY;
if (sx < edge && camX > 0) {
camX -= speed;
}
if (sx > 127 - edge && camX < worldW - 128) {
camX += speed;
}
if (sy < edge && camY > 0) {
camY -= speed;
}
if (sy > 127 - edge && camY < worldH - 128) {
camY += speed;
}
if (btnp('z') || btnp('Enter')) {
for (const t of tiles) {
if (
boxesCollide(
[cx - 1, cy - 1, 2, 2],
[t.x, t.y, 16, 16],
)
) {
t.color = t.color === 7 ? 8 : 7;
}
}
}
}
function draw() {
cls(1);
camera(camX, camY);
rectfill(0, 0, worldW, worldH, 3);
for (const t of tiles) {
rectfill(t.x, t.y, t.x + 16, t.y + 16, t.color);
}
camera(0, 0);
spr('cursor', cx - camX - 3, cy - camY - 3);
}
start({ sprites, sounds: {}, init, update, draw, target });
},
);
Para ir más allá
- Zonas de velocidad — mueve más rápido al mantener una tecla modificadora (por ejemplo,
btn('x')para un impulso de velocidad), útil para mundos grandes - Retroalimentación al pasar — cambia el sprite del cursor o el color de un objeto cuando el cursor está sobre él
- Navegación de menú — usa
btnp('ArrowUp')/btnp('ArrowDown')para saltar entre elementos de una lista en lugar de movimiento libre - Interfaz en espacio de pantalla — dibuja elementos HUD después de restablecer la cámara para que permanezcan fijos mientras el mundo se desplaza