ui/board-view.js

import { COLUMNS, ROWS } from "../core/board.js";

/**
 * CSS-grid board renderer.
 *
 * Manages the 40×25 grid of `.cell` <span> elements inside `#board`.
 * The DOM order is row-major — element index i corresponds to
 *   x = i % COLUMNS, y = Math.floor(i / COLUMNS)
 * which means we fill left-to-right, top-to-bottom as CSS grid expects.
 *
 * Each cell span is kept in a flat array `_els[y * COLUMNS + x]` so we
 * can index by (x, y) without a 2D array.
 */
export class BoardView {
  /**
   * @param {HTMLElement} boardEl   The `#board` container element.
   */
  constructor(boardEl) {
    this._el = boardEl;
    this._els = [];
    this._board = null;
  }

  // ── Initialisation ────────────────────────────────────────────────────────

  /**
   * Attach the view to a board: build DOM elements, subscribe to changes,
   * and render the initial state.
   * @param {Board} board
   */
  attach(board) {
    this._board = board;
    this._buildDom();
    board.onCellChange((cell) => this._updateCell(cell));
    this._renderAll();
  }

  /** Detach from the current board (on board-to-board navigation). */
  detach() {
    this._board = null;
    // DOM elements are rebuilt on next attach()
  }

  // ── DOM construction ──────────────────────────────────────────────────────

  /** (Re)build the grid of span elements. */
  _buildDom() {
    this._el.textContent = ""; // clear previous content
    this._els = new Array(COLUMNS * ROWS);

    const frag = document.createDocumentFragment();
    for (let y = 0; y < ROWS; y++) {
      for (let x = 0; x < COLUMNS; x++) {
        const span = document.createElement("span");
        span.className = "cell";
        span.dataset.x = x;
        span.dataset.y = y;
        frag.appendChild(span);
        this._els[y * COLUMNS + x] = span;
      }
    }
    this._el.appendChild(frag);
  }

  // ── Rendering ─────────────────────────────────────────────────────────────

  /** Re-render every cell on the board (called after attach or board swap). */
  _renderAll() {
    if (!this._board) return;
    for (let y = 0; y < ROWS; y++) {
      for (let x = 0; x < COLUMNS; x++) {
        this._updateCell(this._board.cells[x][y]);
      }
    }
  }

  /**
   * Update the span for a single cell.
   * @param {Cell} cell
   */
  _updateCell(cell) {
    const span = this._els[cell.y * COLUMNS + cell.x];
    if (!span) return;

    const outside = this._board.outside;
    const { entity, fg, bg } = cell.getDisplaySymbol(outside);

    span.textContent = entity ?? " ";
    span.style.color = fg != null ? fg : "transparent";
    span.style.backgroundColor = bg != null ? bg : "transparent";

    // Mark the player's current cell with the cursor class
    const isPlayer = cell.containsPlayer();
    span.classList.toggle("cursor", isPlayer);
  }

  // ── Public helpers ────────────────────────────────────────────────────────

  /**
   * Force a full re-render (e.g. after the outside/inside flag changes).
   */
  rerender() {
    this._renderAll();
  }

  /**
   * Highlight the cell at (x, y) as the cursor location (removes the
   * previous highlight automatically on the next cell update cycle).
   * @param {number} x
   * @param {number} y
   */
  moveCursorTo(x, y) {
    // Clear all cursor classes
    for (const span of this._els) {
      span.classList.remove("cursor");
    }
    const span = this._els[y * COLUMNS + x];
    if (span) span.classList.add("cursor");
  }
}