Back to Mini Samurai
SOURCE / PINNED RELEASE

Made of little things.

Mini Samurai

Release
a7beb798adbc…
Author-recorded commit
4f97be1515b8…
License
LICENSE
Author’s source reference
nostr://npub12lm3rxsva8kfempyjkxupt09e02n3fezurkyadyqfejdc5e0r2kscqty6r/wss%3A%2F%2Fgit.napplet.soy%2F/n-3b28f0efc5b

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

src/hud.ts
import * as THREE from 'three';
import type { Game } from './game/game.js';
import { TEAM_CSS } from './game/game.js';
import type { Fighter } from './game/fighter.js';

interface RowEls {
  root: HTMLDivElement;
  name: HTMLElement;
  hp: HTMLDivElement;
  lag: HTMLDivElement;
  weapon: HTMLSpanElement;
}

/** One health row: name chip, held item, and a skewed ink health bar with a trailing damage bar. */
function makeRow(className: string): RowEls {
  const root = document.createElement('div');
  root.className = className;
  root.innerHTML = `
    <div class="hud-name"><b></b><span class="hud-weapon"></span></div>
    <div class="hud-bar"><div class="hud-lag"></div><div class="hud-hp"></div></div>`;
  return {
    root,
    name: root.querySelector('b')!,
    hp: root.querySelector('.hud-hp')!,
    lag: root.querySelector('.hud-lag')!,
    weapon: root.querySelector('.hud-weapon')!,
  };
}

/** DOM overlay drawn over the 3D view: health, round pips, wave count, banners, popups and player tags. */
export class Hud {
  readonly el: HTMLDivElement;
  private readonly teams: HTMLDivElement[];
  private readonly waveLabel: HTMLDivElement;
  private readonly pips: HTMLDivElement;
  private readonly banner: HTMLDivElement;
  private readonly layer: HTMLDivElement;
  /** Split-screen dividers: one vertical, one horizontal (quarters use both). */
  private readonly splitV: HTMLDivElement;
  private readonly splitH: HTMLDivElement;
  private readonly rows = new Map<Fighter, RowEls>();
  private readonly tags = new Map<Fighter, HTMLDivElement>();
  /** Split screen: a panel in each player's own view with their health, and their nearest foe's. */
  private readonly viewPanels: { root: HTMLDivElement; own: RowEls; foe: RowEls; ownOf: Fighter | null; foeOf: Fighter | null }[] = [];
  /** Trailing "damage just taken" bar, per fighter, shared by every bar that shows them. */
  private readonly lag = new Map<Fighter, number>();
  private popupEls: HTMLDivElement[] = [];
  private bannerKey = '';

  constructor(parent: HTMLElement) {
    this.el = document.createElement('div');
    this.el.className = 'hud';
    this.el.innerHTML = `
      <div class="hud-top">
        <div class="hud-team" data-team="0"></div>
        <div class="hud-mid"><div class="hud-wave" hidden></div><div class="hud-pips"></div></div>
        <div class="hud-team" data-team="1"></div>
      </div>
      <div class="hud-split wide" hidden></div>
      <div class="hud-split" hidden></div>
      <div class="hud-views"></div>
      <div class="hud-layer"></div>
      <div class="hud-banner" aria-live="polite"></div>`;
    parent.append(this.el);
    this.teams = [...this.el.querySelectorAll<HTMLDivElement>('.hud-team')];
    this.waveLabel = this.el.querySelector('.hud-wave')!;
    this.pips = this.el.querySelector('.hud-pips')!;
    this.banner = this.el.querySelector('.hud-banner')!;
    this.layer = this.el.querySelector('.hud-layer')!;
    this.splitV = this.el.querySelector('.hud-split.wide')!;
    this.splitH = this.el.querySelector('.hud-split:not(.wide)')!;
    const views = this.el.querySelector<HTMLDivElement>('.hud-views')!;
    for (let i = 0; i < 4; i++) {
      const root = document.createElement('div');
      root.className = 'hud-view';
      root.hidden = true;
      const own = makeRow('hud-row hud-own');
      const foe = makeRow('hud-row hud-foe');
      root.append(own.root, foe.root);
      views.append(root);
      this.viewPanels.push({ root, own, foe, ownOf: null, foeOf: null });
    }
  }

  /** Rebuild the health rows for a new match. */
  private mode: 'battle' | 'waves' | 'explore' = 'battle';

  setup(fighters: Fighter[], mode: 'battle' | 'waves' | 'explore' = 'battle'): void {
    this.mode = mode;
    this.el.classList.toggle('explore', mode === 'explore');
    this.el.classList.toggle('waves', mode === 'waves');
    for (const t of this.teams) t.replaceChildren();
    for (const tag of this.tags.values()) tag.remove();
    this.rows.clear();
    this.tags.clear();
    this.lag.clear();
    for (const panel of this.viewPanels) {
      panel.ownOf = panel.foeOf = null;
      panel.root.hidden = true;
    }
    for (const f of fighters) {
      const row = makeRow('hud-row');
      this.styleRow(row, f);
      // Free for all has up to four sides: split them between the two corners.
      this.teams[f.team % 2]!.append(row.root);
      this.rows.set(f, row);
      const tag = document.createElement('div');
      tag.className = f.human ? 'hud-tag human' : 'hud-tag';
      tag.style.setProperty('--team', TEAM_CSS[f.team]!);
      tag.textContent = f.human ? f.name : '';
      this.layer.append(tag);
      this.tags.set(f, tag);
    }
  }

  update(game: Game, dt: number): void {
    const s = game.hudState();
    for (const f of game.fighters) this.lag.set(f, Math.max(f.health, (this.lag.get(f) ?? 100) - dt * 30));
    for (const [f, row] of this.rows) this.fillRow(row, f);

    // Split screen: health lives in each player's own view instead of across the top.
    const views = game.views();
    const split = views.length > 1;
    this.el.classList.toggle('split', split);
    this.viewPanels.forEach((panel, i) => {
      const view = split ? views[i] : undefined;
      const me = view?.fighter ?? undefined;
      panel.root.hidden = !view || !me;
      if (!view || !me) return;
      panel.root.style.left = `${view.x}px`;
      panel.root.style.top = `${view.y}px`;
      panel.root.style.width = `${view.w}px`;
      panel.root.classList.toggle('narrow', view.w < 420);
      if (panel.ownOf !== me) this.styleRow(panel.own, (panel.ownOf = me));
      this.fillRow(panel.own, me);
      const foe = game.nearestFoe(me);
      panel.foe.root.hidden = !foe;
      if (foe) {
        if (panel.foeOf !== foe) this.styleRow(panel.foe, (panel.foeOf = foe));
        this.fillRow(panel.foe, foe);
      }
    });
    // Rounds have no time limit. Waves mode shows the wave number and a running tally instead of round pips.
    const waves = this.mode === 'waves';
    this.waveLabel.hidden = !waves;
    const waveText = `Wave ${Math.max(1, s.wave)}`;
    if (waves && this.waveLabel.textContent !== waveText) this.waveLabel.textContent = waveText;
    const pipHtml = waves
      ? `<span class="hud-tally">${s.enemiesLeft} left · ${s.kills} KO${s.kills === 1 ? '' : 's'}</span>`
      : s.wins
          .map((w, team) => `<span style="--team:${TEAM_CSS[team]}">${'●'.repeat(w)}${'○'.repeat(Math.max(0, 2 - w))}</span>`)
          .join('');
    if (this.pips.innerHTML !== pipHtml) this.pips.innerHTML = pipHtml;

    // Banner text with a stamp-in animation whenever it changes.
    const key = s.banner ? `${s.banner.text}|${s.banner.sub ?? ''}` : '';
    if (key !== this.bannerKey) {
      this.bannerKey = key;
      this.banner.innerHTML = s.banner ? `<div class="hud-banner-text">${s.banner.text}</div>${s.banner.sub ? `<div class="hud-banner-sub">${s.banner.sub}</div>` : ''}` : '';
      this.banner.classList.remove('stamp');
      void this.banner.offsetWidth;
      if (s.banner) this.banner.classList.add('stamp');
    }
    const fades = s.banner?.text === 'Fight!' ? 1.4 : s.banner?.text === 'Explore' ? 2.6 : Infinity;
    if (s.banner && s.banner.age > fades) this.banner.classList.add('fade');
    else this.banner.classList.remove('fade');

    // Dividers between the players' views.
    const right = views.find((v) => v.x > 0);
    const lower = views.find((v) => v.y > 0);
    this.splitV.hidden = !right;
    this.splitH.hidden = !lower;
    if (right) this.splitV.style.left = `${right.x}px`;
    if (lower) this.splitH.style.top = `${lower.y}px`;

    // Player tags over heads.
    const head = new THREE.Vector3();
    for (const [f, tag] of this.tags) {
      // In split screen a player's own marker shows in their own view.
      const owner = f.human ? Number(f.name.slice(1)) - 1 : undefined;
      const p = game.project(f.headPos(head).setY(head.y + 0.25), owner);
      tag.hidden = !p || !f.alive;
      if (p) tag.style.transform = `translate(${p.x}px, ${p.y}px)`;
    }

    // Popups.
    while (this.popupEls.length < s.popups.length) {
      const el = document.createElement('div');
      el.className = 'hud-popup';
      this.layer.append(el);
      this.popupEls.push(el);
    }
    this.popupEls.forEach((el, i) => {
      const p = s.popups[i];
      const at = p ? game.project(p.at) : null;
      el.hidden = !p || !at;
      if (!p || !at) return;
      if (el.textContent !== p.text) el.textContent = p.text;
      el.style.color = p.color;
      const pop = Math.min(1, p.age * 8);
      el.style.opacity = String(Math.min(1, (1.1 - p.age) * 3));
      el.style.transform = `translate(${at.x}px, ${at.y}px) translate(-50%, -100%) scale(${0.6 + pop * 0.4})`;
    });
  }

  /** Name chip colour and armour tint for whoever a row shows. */
  private styleRow(row: RowEls, f: Fighter): void {
    row.root.style.setProperty('--team', TEAM_CSS[f.team]!);
    row.root.style.setProperty('--armor', `#${f.livery.armor.toString(16).padStart(6, '0')}`);
    row.name.textContent = f.name;
  }

  private fillRow(row: RowEls, f: Fighter): void {
    row.hp.style.width = `${f.health}%`;
    row.lag.style.width = `${this.lag.get(f) ?? f.health}%`;
    row.root.classList.toggle('hurt', f.hurtAgo < 0.25);
    row.root.classList.toggle('out', !f.alive);
    row.root.classList.toggle('low', f.health > 0 && f.health < 30);
    const held = f.carrying ? `${f.carrying.name} (overhead)` : f.weapon ? f.weapon.def.name : 'Bare hands';
    const power = f.powerLabel;
    const name = power ? `${held} · ${power}` : held;
    if (row.weapon.textContent !== name) row.weapon.textContent = name;
  }

  set visible(v: boolean) {
    this.el.hidden = !v;
  }
}