Back to Wizard Duel
SOURCE / PINNED RELEASE

Made of little things.

Wizard Duel

Release
48b4ed933a32…
Author-recorded commit
3ed8fcbbf62c…
License
LICENSE
Author’s source reference
nostr://npub1ny7l44q0au5xkv3mjgt6zks5f0e2ydefslq4nhh2ve2xypgc2mqqat8qx2/wss%3A%2F%2Fgit.napplet.soy%2F/n-86005d75eb4

Archive hash verified: 4196bfb69e536a86…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/game/pixels.ts
// Tiny pixel-art helpers: a 3×5 bitmap font, arrow runes and sprite drawing.
// Everything is drawn with fillRect on a small canvas that is scaled up with
// nearest-neighbour filtering, so every shape stays crisp and blocky.

export type Dir = 'up' | 'down' | 'left' | 'right';

const FONT: Record<string, string[]> = {
  A: ['.#.', '#.#', '###', '#.#', '#.#'],
  B: ['##.', '#.#', '##.', '#.#', '##.'],
  C: ['.##', '#..', '#..', '#..', '.##'],
  D: ['##.', '#.#', '#.#', '#.#', '##.'],
  E: ['###', '#..', '##.', '#..', '###'],
  F: ['###', '#..', '##.', '#..', '#..'],
  G: ['.##', '#..', '#.#', '#.#', '.##'],
  H: ['#.#', '#.#', '###', '#.#', '#.#'],
  I: ['###', '.#.', '.#.', '.#.', '###'],
  J: ['..#', '..#', '..#', '#.#', '.#.'],
  K: ['#.#', '#.#', '##.', '#.#', '#.#'],
  L: ['#..', '#..', '#..', '#..', '###'],
  M: ['#.#', '###', '###', '#.#', '#.#'],
  N: ['##.', '#.#', '#.#', '#.#', '#.#'],
  O: ['.#.', '#.#', '#.#', '#.#', '.#.'],
  P: ['##.', '#.#', '##.', '#..', '#..'],
  Q: ['.#.', '#.#', '#.#', '##.', '.##'],
  R: ['##.', '#.#', '##.', '#.#', '#.#'],
  S: ['.##', '#..', '.#.', '..#', '##.'],
  T: ['###', '.#.', '.#.', '.#.', '.#.'],
  U: ['#.#', '#.#', '#.#', '#.#', '###'],
  V: ['#.#', '#.#', '#.#', '#.#', '.#.'],
  W: ['#.#', '#.#', '###', '###', '#.#'],
  X: ['#.#', '#.#', '.#.', '#.#', '#.#'],
  Y: ['#.#', '#.#', '.#.', '.#.', '.#.'],
  Z: ['###', '..#', '.#.', '#..', '###'],
  '0': ['###', '#.#', '#.#', '#.#', '###'],
  '1': ['.#.', '##.', '.#.', '.#.', '###'],
  '2': ['##.', '..#', '.#.', '#..', '###'],
  '3': ['##.', '..#', '.#.', '..#', '##.'],
  '4': ['#.#', '#.#', '###', '..#', '..#'],
  '5': ['###', '#..', '##.', '..#', '##.'],
  '6': ['.##', '#..', '###', '#.#', '###'],
  '7': ['###', '..#', '.#.', '.#.', '.#.'],
  '8': ['###', '#.#', '###', '#.#', '###'],
  '9': ['###', '#.#', '###', '..#', '##.'],
  '!': ['.#.', '.#.', '.#.', '...', '.#.'],
  '?': ['##.', '..#', '.#.', '...', '.#.'],
  '.': ['...', '...', '...', '...', '.#.'],
  ',': ['...', '...', '...', '.#.', '#..'],
  "'": ['.#.', '.#.', '...', '...', '...'],
  '-': ['...', '...', '###', '...', '...'],
  ':': ['...', '.#.', '...', '.#.', '...'],
  '/': ['..#', '..#', '.#.', '#..', '#..'],
  '+': ['...', '.#.', '###', '.#.', '...'],
  '(': ['.#.', '#..', '#..', '#..', '.#.'],
  ')': ['.#.', '..#', '..#', '..#', '.#.'],
  ' ': ['...', '...', '...', '...', '...'],
};

export function textWidth(text: string, scale = 1): number {
  return text.length === 0 ? 0 : (text.length * 4 - 1) * scale;
}

/** Draw text with the bitmap font. A dark shadow keeps it readable on any background. */
export function drawText(
  ctx: CanvasRenderingContext2D,
  text: string,
  x: number,
  y: number,
  color: string,
  options: { scale?: number; align?: 'left' | 'center' | 'right'; shadow?: string | null } = {},
): void {
  const scale = options.scale ?? 1;
  const upper = text.toUpperCase();
  let left = Math.round(x);
  if (options.align === 'center') left = Math.round(x - textWidth(upper, scale) / 2);
  if (options.align === 'right') left = Math.round(x - textWidth(upper, scale));
  const shadow = options.shadow === undefined ? '#120a1f' : options.shadow;
  const passes: Array<[string, number]> = shadow ? [[shadow, scale], [color, 0]] : [[color, 0]];
  for (const [fill, offset] of passes) {
    ctx.fillStyle = fill;
    for (let i = 0; i < upper.length; i++) {
      const glyph = FONT[upper[i]] ?? FONT['?'];
      for (let row = 0; row < 5; row++) {
        for (let col = 0; col < 3; col++) {
          if (glyph[row][col] === '#') {
            ctx.fillRect(left + (i * 4 + col) * scale + offset, Math.round(y) + row * scale + offset, scale, scale);
          }
        }
      }
    }
  }
}

const ARROW_UP = ['..#..', '.###.', '#.#.#', '..#..', '..#..'];

function rotateArrow(dir: Dir): string[] {
  const rows: string[] = [];
  for (let r = 0; r < 5; r++) {
    let line = '';
    for (let c = 0; c < 5; c++) {
      if (dir === 'up') line += ARROW_UP[r][c];
      else if (dir === 'down') line += ARROW_UP[4 - r][c];
      else if (dir === 'left') line += ARROW_UP[c][r];
      else line += ARROW_UP[4 - c][r];
    }
    rows.push(line);
  }
  return rows;
}

const ARROWS: Record<Dir, string[]> = {
  up: rotateArrow('up'),
  down: rotateArrow('down'),
  left: rotateArrow('left'),
  right: rotateArrow('right'),
};

/** Draw one arrow rune (5×5) on a small dark tile. */
export function drawArrow(
  ctx: CanvasRenderingContext2D,
  dir: Dir,
  x: number,
  y: number,
  color: string,
  tile: string | null = '#120a1f',
): void {
  const left = Math.round(x);
  const top = Math.round(y);
  if (tile) {
    ctx.fillStyle = tile;
    ctx.fillRect(left - 1, top - 1, 7, 7);
  }
  ctx.fillStyle = color;
  const rows = ARROWS[dir];
  for (let r = 0; r < 5; r++) {
    for (let c = 0; c < 5; c++) {
      if (rows[r][c] === '#') ctx.fillRect(left + c, top + r, 1, 1);
    }
  }
}

/**
 * Draw a sprite described by rows of characters. Each character is looked up in
 * the palette; '.' (or any character missing from the palette) is transparent.
 */
export function drawSprite(
  ctx: CanvasRenderingContext2D,
  rows: string[],
  palette: Record<string, string>,
  x: number,
  y: number,
  options: { flipX?: boolean; flipY?: boolean; scale?: number } = {},
): void {
  const scale = options.scale ?? 1;
  const height = rows.length;
  const width = rows[0]?.length ?? 0;
  const left = Math.round(x);
  const top = Math.round(y);
  for (let r = 0; r < height; r++) {
    const row = rows[r];
    for (let c = 0; c < width; c++) {
      const color = palette[row[c]];
      if (!color) continue;
      const dc = options.flipX ? width - 1 - c : c;
      const dr = options.flipY ? height - 1 - r : r;
      ctx.fillStyle = color;
      ctx.fillRect(left + dc * scale, top + dr * scale, scale, scale);
    }
  }
}