Back to Squad Table
SOURCE / PINNED RELEASE

Made of little things.

Squad Table

Release
d36201f8ed4f…
Author-recorded commit
7b9bb681d6ba…
License
LICENSE
Author’s source reference
nostr://npub1sj2vketepqw9v6fmge5fdzuj7atj9sv6qv2jss05n2z6rl64lcfqumdsnt/wss%3A%2F%2Fgit.napplet.soy%2F/n-c043b0c0f1d

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

src/counters.ts
// Original counter artwork as SVG markup. Units use NATO-style role boxes on
// nation-coloured chits; markers use tone colours so states read at a glance.

import type { NationId, Template } from './catalog.js';
import { templateOf, type Counter } from './model.js';

export const COUNTER = 34;

export const NATION_STYLE: Record<NationId, { face: string; edge: string; ink: string }> = {
  ge: { face: '#97a3a6', edge: '#55605f', ink: '#101517' },
  us: { face: '#a19a5a', edge: '#5c5829', ink: '#14140a' },
  ru: { face: '#c4a66f', edge: '#77613a', ink: '#1e150a' },
  mk: { face: '#e9e1c8', edge: '#8c8268', ink: '#1d1a12' },
};

const TONE_STYLE: Record<NonNullable<Template['tone']>, { face: string; edge: string; ink: string }> = {
  fire: { face: '#b4462f', edge: '#6e2516', ink: '#fff3df' },
  move: { face: '#dcb84a', edge: '#86691c', ink: '#211a06' },
  state: { face: '#e5dcc3', edge: '#8a7f62', ink: '#2a2214' },
  terrain: { face: '#5c5b52', edge: '#2f2e29', ink: '#f2ead3' },
  info: { face: '#f3ecd6', edge: '#8c8268', ink: '#1d1a12' },
};

export function esc(text: string): string {
  return text.replace(/[&<>"']/g, (ch) => `&#${ch.charCodeAt(0)};`);
}

export function styleOf(t: Template) {
  if (t.nat === 'mk') return TONE_STYLE[t.tone ?? 'info'];
  return NATION_STYLE[t.nat];
}

function glyph(t: Template, ink: string): string {
  const box = `<rect x="-8" y="-13" width="16" height="9.5" fill="none" stroke="${ink}" stroke-width="1.1"/>`;
  switch (t.type) {
    case 'squad':
      return `${box}<path d="M-8 -13 L8 -3.5 M8 -13 L-8 -3.5" stroke="${ink}" stroke-width="1"/>`;
    case 'half':
      return `${box}<path d="M-8 -3.5 L8 -13" stroke="${ink}" stroke-width="1"/>`;
    case 'crew':
      return `${box}<path d="M-8 -8.25 H8" stroke="${ink}" stroke-width="1"/><circle cx="0" cy="-8.25" r="2" fill="${ink}"/>`;
    case 'leader':
      return `<path d="M0 -14.5 L2.1 -10 L7 -9.6 L3.3 -6.5 L4.4 -1.8 L0 -4.3 L-4.4 -1.8 L-3.3 -6.5 L-7 -9.6 L-2.1 -10 Z" fill="${ink}"/>`;
    case 'weapon':
      return `<path d="M-9 -9 H7 M7 -10.5 V-7.5 M-2 -9 L-5 -3.5 M-2 -9 L1 -3.5" stroke="${ink}" stroke-width="1.4" fill="none" stroke-linecap="round"/>`;
    case 'gun':
      return `<circle cx="-4" cy="-6" r="3" fill="none" stroke="${ink}" stroke-width="1.2"/><circle cx="4" cy="-6" r="3" fill="none" stroke="${ink}" stroke-width="1.2"/><path d="M-6 -11 L10 -14" stroke="${ink}" stroke-width="1.6" stroke-linecap="round"/>`;
    case 'vehicle':
      return `<rect x="-9" y="-9" width="18" height="6" rx="3" fill="none" stroke="${ink}" stroke-width="1.2"/><rect x="-4" y="-13" width="8" height="4" fill="${ink}"/><path d="M4 -11 H11" stroke="${ink}" stroke-width="1.4"/>`;
    default:
      return '';
  }
}

export type CounterStatus = { cx?: boolean; moved?: boolean; fired?: 'prep' | 'final' | 'adv'; pin?: boolean };

/** One counter centred on the origin. */
export function counterSvg(counter: Counter, extra = '', status: CounterStatus = {}): string {
  const t = templateOf(counter);
  const s = styleOf(t);
  const half = COUNTER / 2;
  const label = esc(counter.label ?? t.label);
  const isMarker = t.type === 'marker' || t.type === 'fort';
  const flipped = counter.face === 1;
  const parts: string[] = [
    `<rect x="${-half + 1.6}" y="${-half + 1.6}" width="${COUNTER}" height="${COUNTER}" rx="3" fill="#000" opacity="0.35"/>`,
    `<rect x="${-half}" y="${-half}" width="${COUNTER}" height="${COUNTER}" rx="3" fill="${s.face}" stroke="${s.edge}" stroke-width="1.4"/>`,
    `<rect x="${-half + 2}" y="${-half + 2}" width="${COUNTER - 4}" height="${COUNTER - 4}" rx="2" fill="none" stroke="#fff" stroke-opacity="0.28" stroke-width="0.8"/>`,
  ];
  if (isMarker) {
    const back = flipped && t.back ? esc(t.back) : label;
    const size = back.length > 7 ? 6.4 : back.length > 4 ? 7.6 : back.length > 2 ? 10 : 14;
    parts.push(
      `<text y="${size / 3}" text-anchor="middle" font-size="${size}" font-weight="800" fill="${s.ink}" class="ctr-text">${back}</text>`,
    );
  } else {
    parts.push(glyph(t, s.ink));
    if (t.tag) {
      parts.push(`<text y="-0.2" text-anchor="middle" font-size="5" letter-spacing="0.3" fill="${s.ink}" opacity="0.8" class="ctr-text">${esc(t.tag.toUpperCase())}</text>`);
    }
    const size = label.length > 6 ? 7 : 9.5;
    parts.push(
      `<text y="11.5" text-anchor="middle" font-size="${size}" font-weight="800" fill="${s.ink}" class="ctr-text" opacity="${flipped ? 0.55 : 1}">${label}</text>`,
    );
    if (flipped) {
      parts.push(
        `<path d="M${-half} ${half - 9} L${half - 9} ${-half} H${half} V${-half + 6} L${-half + 6} ${half} H${-half} Z" fill="#7b1f14" opacity="0.82"/>`,
        `<text transform="rotate(-45)" y="2" text-anchor="middle" font-size="5.6" font-weight="800" letter-spacing="0.6" fill="#ffe9d6" class="ctr-text">${esc(t.back ?? 'BROKEN')}</text>`,
      );
    }
    if ((t.type === 'vehicle' || t.type === 'gun') && counter.rot >= 0) {
      const angle = -90 + counter.rot * 60;
      parts.push(
        `<g transform="rotate(${angle + 90})"><path d="M0 ${-half - 5} L4 ${-half - 0.5} H-4 Z" fill="${s.edge}" stroke="#fff" stroke-width="0.6"/></g>`,
      );
    }
  }
  if (status.moved) {
    parts.push(
      `<circle cx="${-half + 5}" cy="${-half + 5}" r="3.6" fill="#2b2b22" stroke="#f0e6c8" stroke-width="0.7"/><path d="M${-half + 3.2} ${-half + 5} l1.3 1.4 l2.4 -2.7" stroke="#f0e6c8" stroke-width="0.9" fill="none"/>`,
    );
  }
  if (status.cx ?? counter.cx) {
    parts.push(
      `<rect x="${-half + 3}" y="${half - 8.5}" width="13" height="7" rx="1.5" fill="#dcb84a" stroke="#6b5414" stroke-width="0.6"/><text x="${-half + 9.5}" y="${half - 3}" text-anchor="middle" font-size="5.4" font-weight="800" fill="#211a06" class="ctr-text">CX</text>`,
    );
  }
  const fired = 'fired' in status ? status.fired : counter.fired;
  if (fired) {
    const tag = fired === 'prep' ? 'PF' : fired === 'final' ? 'FF' : 'AF';
    parts.push(
      `<rect x="${half - 14}" y="${-half - 3}" width="13" height="7.5" rx="1.5" fill="#b4462f" stroke="#5e1d10" stroke-width="0.6"/><text x="${half - 7.5}" y="${-half + 2.6}" text-anchor="middle" font-size="5.4" font-weight="800" fill="#fff3df" class="ctr-text">${tag}</text>`,
    );
  }
  if (status.pin ?? counter.pin) {
    parts.push(
      `<rect x="${half - 16}" y="${half - 8.5}" width="15" height="7" rx="1.5" fill="#e07b2a" stroke="#6e3608" stroke-width="0.6"/><text x="${half - 8.5}" y="${half - 3}" text-anchor="middle" font-size="5.2" font-weight="800" fill="#1d1004" class="ctr-text">PIN</text>`,
    );
  }
  return `<g class="ctr" data-id="${esc(counter.id)}" ${extra}>${parts.join('')}</g>`;
}

/** A standalone small SVG for panels and the tray. */
export function counterIcon(counter: Counter, size = 40): string {
  return `<svg class="ctr-icon" viewBox="-21 -21 42 42" width="${size}" height="${size}" aria-hidden="true">${counterSvg(counter)}</svg>`;
}