* notes / canvas

An isometric shooter in HTML: math, canvas and tile maps

Construí un shooter isométrico en HTML5 Canvas: proyección, conversión de coordenadas, mapa de tiles, ordenación por profundidad y disparos pooled. Lo que aprendí cuando las matemáticas encajaron.

October 14, 20256 min read#canvas#juegos#isométrico#javascript

Contexto

Lo isométrico es una mentira útil. No es una verdadera proyección isométrica (los ángulos no son los 30 grados clásicos), sino una proyección dimétrica 2:1: las baldosas se dibujan en forma de rombo, con el doble de anchura que de altura. Ese sesgo simplifica enormemente las matemáticas porque las divisiones se vuelven potencias de dos, los enteros encajan en píxeles y los sprites preexistentes encajan con un margen modesto.

El motor que construí tiene cuatro responsabilidades: dibujar el suelo en forma de rombos, situar entidades en el mundo, traducir el clic del jugador a una coordenada del mundo y ordenar lo que se pinta para que un personaje detrás de una columna quede correctamente ocluido.

La proyección, en una imagen

yxcartesianoisométrico

Las dos fórmulas que rigen todo son sencillas. Dado un tamaño de baldosa de w píxeles de ancho por h píxeles de alto, una posición de mundo (tx, ty, tz) (donde tz es elevación) se proyecta en pantalla como:

sx = (tx - ty) * w / 2
sy = (tx + ty) * h / 2 - tz

La inversa, necesaria para convertir el clic del ratón en coordenada del mundo, es:

tx = (sx / (w/2) + sy / (h/2)) / 2
ty = (sy / (h/2) - sx / (w/2)) / 2

Esa simetría es la que más tiempo me llevó interiorizar. La primera vez que vi el cursor del ratón seleccionar exactamente la baldosa correcta, incluso cerca de los bordes del rombo, sentí una pequeña descarga eléctrica de satisfacción.

Implementación: el bucle de dibujado

const TILE_W = 64;
const TILE_H = 32;
 
function worldToScreen(tx, ty, tz = 0, camera) {
  return {
    x: (tx - ty) * (TILE_W / 2) - camera.x,
    y: (tx + ty) * (TILE_H / 2) - tz - camera.y,
  };
}
 
function screenToWorld(sx, sy, camera) {
  const x = sx + camera.x;
  const y = sy + camera.y;
  return {
    x: (x / (TILE_W / 2) + y / (TILE_H / 2)) / 2,
    y: (y / (TILE_H / 2) - x / (TILE_W / 2)) / 2,
  };
}
 
function drawTile(ctx, tx, ty, camera, color) {
  const { x, y } = worldToScreen(tx, ty, 0, camera);
  ctx.beginPath();
  ctx.moveTo(x,            y + TILE_H / 2);
  ctx.lineTo(x + TILE_W/2, y);
  ctx.lineTo(x + TILE_W,   y + TILE_H / 2);
  ctx.lineTo(x + TILE_W/2, y + TILE_H);
  ctx.closePath();
  ctx.fillStyle = color;
  ctx.fill();
  ctx.strokeStyle = "rgba(0,0,0,0.25)";
  ctx.stroke();
}
 
function drawWorld(ctx, map, entities, camera) {
  // Suelo primero, recorrido en orden de tiles
  for (let ty = 0; ty < map.h; ty++) {
    for (let tx = 0; tx < map.w; tx++) {
      drawTile(ctx, tx, ty, camera, map.tiles[ty * map.w + tx].color);
    }
  }
 
  // Entidades ordenadas por profundidad (algoritmo del pintor)
  const ordered = [...entities].sort((a, b) =>
    (a.x + a.y + a.z * 0.001) - (b.x + b.y + b.z * 0.001),
  );
 
  for (const e of ordered) {
    const { x, y } = worldToScreen(e.x, e.y, e.z, camera);
    ctx.drawImage(e.sprite, x - e.sprite.width / 2, y - e.sprite.height + TILE_H / 2);
  }
}
 
canvas.addEventListener("pointerdown", (ev) => {
  const rect = canvas.getBoundingClientRect();
  const sx = ev.clientX - rect.left;
  const sy = ev.clientY - rect.top;
  const world = screenToWorld(sx, sy, camera);
  shoot(player, world);
});

La ordenación por profundidad (painter's algorithm) consiste en pintar los objetos del fondo hacia el frente. En un mundo isométrico, el orden natural es la suma x + y: cuanto mayor, más cerca de la cámara. Sumar z * 0.001 resuelve los empates en favor del objeto más alto, lo que importa cuando un personaje salta por encima de una caja a la misma posición de baldosa. No es un z-buffer real, que requeriría WebGL, pero basta para escenas razonables.

Disparos pooled y línea de visión

Los disparos no se crean ni se destruyen: se reciclan desde un pool preasignado. Crear y destruir miles de objetos por segundo es la forma más rápida de hacer que el recolector de basura de V8 te arruine la tasa de frames.

class BulletPool {
  constructor(size) {
    this.pool = Array.from({ length: size }, () => ({ active: false, x: 0, y: 0, vx: 0, vy: 0, ttl: 0 }));
  }
  spawn(x, y, vx, vy) {
    const b = this.pool.find((b) => !b.active);
    if (!b) return; // límite alcanzado, mejor no asignar
    b.active = true; b.x = x; b.y = y; b.vx = vx; b.vy = vy; b.ttl = 1.2;
  }
  update(dt, world) {
    for (const b of this.pool) {
      if (!b.active) continue;
      b.x += b.vx * dt;
      b.y += b.vy * dt;
      b.ttl -= dt;
      if (b.ttl <= 0 || world.solidAt(b.x, b.y)) b.active = false;
    }
  }
}

Para la IA enemiga, un cálculo de línea de visión con Bresenham basta para decidir si el jugador es visible: se traza una línea de baldosas entre enemigo y jugador y se descarta si alguna es opaca. Es un algoritmo de los años setenta que sigue siendo perfecto para esto. La sutileza está en respetar el mismo sistema de coordenadas cuando se pasa de la posición real (flotante) del enemigo al índice entero de la baldosa.

El momento en que todo encaja

Hubo una tarde concreta en que las piezas dejaron de ser independientes. Hasta entonces, las baldosas se pintaban bien, el jugador se movía, los disparos funcionaban, pero la sensación era la de un puzle desmontado: cuando le añadía elevación a un objeto, dejaba de ordenarse correctamente; cuando el ratón estaba cerca del borde de una baldosa, seleccionaba la equivocada. Al unificar todo bajo dos funciones (worldToScreen y su inversa) y al introducir z como término que sólo afecta a la y proyectada, el mundo empezó a comportarse de manera consistente. Esa coherencia interna es lo que diferencia un juego que se siente como un juego de una colección de píxeles que se mueven.

Hallazgos

  • Empezar con una baldosa 2:1 (64×32 o 128×64) ahorra horas de matemáticas raras frente a relaciones distintas.
  • requestAnimationFrame con un dt calculado entre frames es suficiente para la mayoría de juegos; añadir un fixed timestep sólo si entra la física.
  • Una textura única con todos los sprites (un atlas) y drawImage(atlas, sx, sy, sw, sh, dx, dy, dw, dh) rinde mejor que cargar cada imagen por separado.
  • El pintado del suelo se acelera enormemente cacheando el mapa visible en un canvas secundario y desplazándolo con la cámara, en vez de redibujar cada baldosa cada frame.
  • Para conservar nitidez, conviene desactivar el suavizado de imagen: ctx.imageSmoothingEnabled = false. Sin ese ajuste, los sprites pixel-art se ven borrosos a determinadas escalas.