Back to Pickle Rustlers
SOURCE / PINNED RELEASE

Made of little things.

Pickle Rustlers

Release
039e51f55e34…
Author-recorded commit
40608586a8a1…
License
LICENSE
Author’s source reference
nostr://npub12lm3rxsva8kfempyjkxupt09e02n3fezurkyadyqfejdc5e0r2kscqty6r/wss%3A%2F%2Fgit.napplet.soy%2F/n-bad44e7ab35

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

src/game/hud.ts
// Wanted-poster HUD: DOM badges over the WebGL scene, plus the poster screens.

export interface HudState {
  dayLabel: string;
  brined: number;
  quota: number;
  score: number;
  timeLeft: number;
  dayLength: number;
  tears: number;
  maxTears: number;
  shells: number;
  reloading: boolean;
  ropes: number;
  maxRopes: number;
  tankTime: number;
  sauceTime: number;
  bossHp: number; // 0 hides the boss bar
  bossMax: number;
}

import type { ScoreCard, ShareResult } from '../share/types.js';

export type PosterKind = 'title' | 'dayEnd' | 'gameOver' | 'paused' | 'victory';

export interface PosterData {
  day?: number;
  days?: number;
  score?: number;
  best?: number;
  newBest?: boolean;
  wins?: number;
  brined?: number;
  onions?: number;
  tears?: number;
  bonus?: number;
  healed?: number;
  reason?: string;
  nextName?: string;
  nextBlurb?: string;
  nextLabel?: string;
}

export interface PosterAction {
  label: string;
  run: () => void;
}

export interface ComposerOptions {
  card: Promise<ScoreCard>;
  text: string;
  post: (png: Blob, text: string, onStatus: (text: string) => void) => Promise<ShareResult>;
  back: () => void;
}

const $ = <T extends HTMLElement>(id: string) => {
  const el = document.getElementById(id);
  if (!el) throw new Error(`Missing #${id}`);
  return el as T;
};

const quips = [
  'Them pickles ain’t gonna brine themselves.',
  'Smells like vinegar and victory.',
  'The onions are regrouping. Layer by layer.',
  'Your horse demands hazard pay.',
  'Somewhere, a sandwich sheds a single tear.',
];

const KEYS =
  '<p class="keys">Defaults (shell may rebind): <kbd>W A S D</kbd> ride &middot; <kbd>Space</kbd> shoot &middot; <kbd>E</kbd> lasso &middot; <kbd>P</kbd> pause</p>';

const n = (v: number | undefined) => (v ?? 0).toLocaleString('en-US');

export class Hud {
  private hud = $('hud');
  private day = $('dayNum');
  private brined = $('brined');
  private quota = $('quota');
  private score = $('score');
  private clock = $('clock');
  private sunHand = $('sunHand');
  private tears = $('tears');
  private shells = $('shells');
  private ropes = $('ropes');
  private power = $('power');
  private boss = $('boss');
  private bossFill = $('bossFill');
  private toastEl = $('toast');
  private bannerEl = $('banner');
  private hurtEl = $('hurt');
  private wrap = $('posterWrap');
  private poster = $('poster');
  private touch = $('touch');
  private last: Partial<Record<string, string>> = {};
  private toastTimer = 0;
  private bannerTimer = 0;
  private pending: (() => void) | null = null;

  showHud(visible: boolean): void {
    this.hud.hidden = !visible;
  }

  setTouch(visible: boolean): void {
    this.touch.hidden = !visible;
  }

  private set(key: string, el: HTMLElement, value: string, html = false): void {
    if (this.last[key] === value) return;
    this.last[key] = value;
    if (html) el.innerHTML = value;
    else el.textContent = value;
  }

  update(s: HudState): void {
    this.set('day', this.day, s.dayLabel);
    this.set('brined', this.brined, String(s.brined));
    this.set('quota', this.quota, String(s.quota));
    this.brined.parentElement?.classList.toggle('done', s.brined >= s.quota);
    this.set('score', this.score, n(s.score));
    const secs = Math.max(0, Math.ceil(s.timeLeft));
    this.set('clock', this.clock, String(secs));
    const angle = -90 + (1 - s.timeLeft / s.dayLength) * 180;
    const rot = `rotate(${angle.toFixed(1)}deg)`;
    if (this.last.sun !== rot) {
      this.last.sun = rot;
      this.sunHand.style.transform = rot;
    }
    this.clock.parentElement?.classList.toggle('late', secs <= 15);

    let tears = '';
    for (let i = 0; i < s.maxTears; i++) tears += `<i class="tear${i < s.tears ? ' cried' : ''}"></i>`;
    this.set('tears', this.tears, tears, true);
    this.tears.setAttribute('aria-label', `${s.tears} of ${s.maxTears} tears cried`);

    let shells = '';
    if (s.tankTime > 0) shells = '<span class="lbl">CANNON</span><i class="shell big"></i>';
    else {
      for (let i = 0; i < 2; i++) shells += `<i class="shell${i < s.shells ? '' : ' spent'}"></i>`;
      if (s.reloading) shells += '<span class="lbl blink">RELOAD</span>';
    }
    this.set('shells', this.shells, shells, true);

    let ropes = '';
    for (let i = 0; i < s.maxRopes; i++) ropes += `<i class="rope${i < s.ropes ? ' used' : ''}"></i>`;
    this.set('ropes', this.ropes, ropes, true);
    this.ropes.setAttribute('aria-label', `${s.ropes} of ${s.maxRopes} ropes towing pickles`);

    const parts: string[] = [];
    if (s.tankTime > 0) parts.push(`<span class="pw tank">ABRAMS ${Math.ceil(s.tankTime)}</span>`);
    if (s.sauceTime > 0) parts.push(`<span class="pw sauce">HOT SAUCE ${Math.ceil(s.sauceTime)}</span>`);
    this.set('power', this.power, parts.join(''), true);

    this.boss.hidden = s.bossHp <= 0;
    if (s.bossHp > 0) {
      const pct = `${((s.bossHp / s.bossMax) * 100).toFixed(1)}%`;
      if (this.last.boss !== pct) {
        this.last.boss = pct;
        this.bossFill.style.width = pct;
        this.boss.setAttribute('aria-label', `Onion King: ${s.bossHp} of ${s.bossMax} layers left`);
      }
    }
  }

  toast(text: string, kind: 'good' | 'bad' | 'info' = 'info'): void {
    this.toastEl.textContent = text;
    this.toastEl.className = `toast show ${kind}`;
    // Restart the stamp animation.
    void this.toastEl.offsetWidth;
    this.toastEl.classList.add('stamp');
    window.clearTimeout(this.toastTimer);
    this.toastTimer = window.setTimeout(() => (this.toastEl.className = 'toast'), 1800);
  }

  /** The day-intro card: "DAY 2 OF 5 / Onion Stampede / blurb". */
  banner(label: string, name: string, blurb: string): void {
    this.bannerEl.innerHTML = `<span class="b-day">${label}</span><span class="b-name">${name}</span><span class="b-blurb">${blurb}</span>`;
    this.bannerEl.className = 'banner';
    void this.bannerEl.offsetWidth;
    this.bannerEl.className = 'banner show';
    window.clearTimeout(this.bannerTimer);
    this.bannerTimer = window.setTimeout(() => (this.bannerEl.className = 'banner'), 3400);
  }

  /** Watery blue edges when an onion makes you cry. */
  hurtFlash(): void {
    this.hurtEl.className = 'hurt';
    void this.hurtEl.offsetWidth;
    this.hurtEl.className = 'hurt show';
  }

  private clearTransient(): void {
    window.clearTimeout(this.toastTimer);
    window.clearTimeout(this.bannerTimer);
    this.toastEl.className = 'toast';
    this.bannerEl.className = 'banner';
  }

  hidePoster(): void {
    this.wrap.hidden = true;
    this.pending = null;
  }

  get posterOpen(): boolean {
    return !this.wrap.hidden;
  }

  /** Keyboard/gamepad confirm runs the poster's main action once, same as clicking it. */
  confirm(): void {
    this.pending?.();
  }

  showPoster(kind: PosterKind, data: PosterData, action: () => void, alts: PosterAction[] = []): void {
    this.clearTransient();
    const best = data.best ? `<p class="best">Best bounty: <b>${n(data.best)}</b>${data.wins ? ` &middot; <span class="star" aria-hidden="true">&#9733;</span> West tamed ${data.wins}&times;` : ''}</p>` : '';
    let html = '';
    if (kind === 'title') {
      html = `
        <p class="wanted">WANTED</p>
        <p class="sub">Runaway pickles &middot; dead or brined</p>
        <div class="mugshots" aria-hidden="true"><span class="mug pickle"></span><span class="mug onion"></span><span class="mug tank"></span></div>
        <ul class="rules">
          <li><b>Lasso</b> pickles and drag them into the Giant Jar before sundown.</li>
          <li><b>Shotgun</b> the onion outlaws. They steal pickles and make you cry.</li>
          <li>Climb into the <b>Abrams</b>. Drink the <b>hot sauce</b>.</li>
          <li>Last <b>${data.days} days</b> and peel the <b>Onion King</b> to save the West.</li>
        </ul>
        ${KEYS}
        ${best}`;
    } else if (kind === 'dayEnd') {
      html = `
        <p class="wanted small">DAY ${data.day} TAMED</p>
        <p class="sub">${quips[(data.day ?? 0) % quips.length]}</p>
        <dl class="stats">
          <div><dt>Pickles brined</dt><dd>${data.brined}</dd></div>
          <div><dt>Onions peeled</dt><dd>${data.onions}</dd></div>
          <div><dt>Daylight bonus</dt><dd>+${n(data.bonus)}</dd></div>
          <div><dt>Bounty</dt><dd>${n(data.score)}</dd></div>
        </dl>
        ${data.healed ? `<p class="heal">A good night's sleep dried ${data.healed} tear${data.healed > 1 ? 's' : ''}.</p>` : ''}
        <div class="next"><span class="next-day">${data.nextLabel}</span><b>${data.nextName}</b><span>${data.nextBlurb}</span></div>`;
    } else if (kind === 'gameOver') {
      html = `
        <p class="wanted small">${data.reason === 'tears' ? 'CRIED OUT' : 'SUNDOWN'}</p>
        <p class="sub">${data.reason === 'tears' ? 'Too many onions. Your eyes gave up.' : 'The pickles got away into the night.'}</p>
        <dl class="stats">
          <div><dt>Days survived</dt><dd>${(data.day ?? 1) - 1}</dd></div>
          <div><dt>Final bounty</dt><dd>${n(data.score)}</dd></div>
        </dl>
        ${data.newBest ? '<p class="stamp-new">NEW BEST!</p>' : best}`;
    } else if (kind === 'victory') {
      html = `
        <div class="badge-star" aria-hidden="true"><span>&#9733;</span></div>
        <p class="wanted small">THE WEST IS BRINED</p>
        <p class="sub">The Onion King is peeled. Brine Gulch can finally make sandwiches again.</p>
        <dl class="stats">
          <div><dt>Pickles brined</dt><dd>${data.brined}</dd></div>
          <div><dt>Onions peeled</dt><dd>${data.onions}</dd></div>
          <div><dt>Tears cried</dt><dd>${data.tears}</dd></div>
          <div><dt>Reward paid</dt><dd>${n(data.score)}</dd></div>
        </dl>
        ${data.newBest ? '<p class="stamp-new">NEW BEST!</p>' : best}`;
    } else {
      html = `<p class="wanted small">PAUSED</p><p class="sub">The pickles are waiting. Patiently. Ominously.</p>${KEYS}`;
    }
    const label = { title: 'Saddle up', dayEnd: 'Ride on', gameOver: 'Ride again', paused: 'Resume', victory: 'Ride again' }[kind];
    const altButtons = alts.map((alt, i) => `<button type="button" class="go alt" id="posterAlt${i}">${alt.label}</button>`).join('');
    this.poster.innerHTML = `${html}<div class="actions"><button type="button" class="go" id="posterGo">${label}</button>${altButtons}</div>`;
    this.poster.dataset.kind = kind;
    this.wrap.hidden = false;
    let done = false;
    const once = (fn: () => void) => () => {
      if (done) return;
      done = true;
      this.pending = null;
      fn();
    };
    const run = once(action);
    this.pending = run;
    const go = $('posterGo');
    go.addEventListener('click', run);
    alts.forEach((alt, i) => $(`posterAlt${i}`).addEventListener('click', once(alt.run)));
    go.focus({ preventScroll: true });
  }

  /** Score-card composer: preview, editable note, Post and Back. */
  showComposer(opts: ComposerOptions): void {
    this.clearTransient();
    this.poster.innerHTML = `
      <p class="wanted small">POST YOUR BOUNTY</p>
      <figure class="share-preview" id="sharePreview"><span class="share-wait">Painting your poster&hellip;</span></figure>
      <label class="share-label" for="shareText">Your note</label>
      <textarea id="shareText" class="share-text" rows="3" maxlength="800"></textarea>
      <p class="share-status" id="shareStatus" role="status"></p>
      <div class="actions">
        <button type="button" class="go" id="sharePost" disabled>Post</button>
        <button type="button" class="go alt" id="shareBack">Back</button>
      </div>`;
    this.poster.dataset.kind = 'share';
    this.wrap.hidden = false;
    // Keyboard and gamepad confirm must not restart the run while composing.
    this.pending = null;

    const preview = $('sharePreview');
    const text = $<HTMLTextAreaElement>('shareText');
    const status = $('shareStatus');
    const postBtn = $<HTMLButtonElement>('sharePost');
    const backBtn = $<HTMLButtonElement>('shareBack');
    text.value = opts.text;
    let png: Blob | null = null;
    let busy = false;
    let closed = false;
    const setStatus = (msg: string, kind = '') => {
      if (closed) return;
      status.textContent = msg;
      status.className = `share-status ${kind}`;
    };

    opts.card.then(
      (card) => {
        if (closed) return;
        card.preview.setAttribute('role', 'img');
        card.preview.setAttribute('aria-label', 'Score card preview');
        preview.replaceChildren(card.preview);
        png = card.png;
        postBtn.disabled = false;
        postBtn.focus({ preventScroll: true });
      },
      () => setStatus('Could not paint the score card.', 'bad'),
    );

    postBtn.addEventListener('click', () => {
      if (!png || busy) return;
      busy = true;
      postBtn.disabled = true;
      text.readOnly = true;
      opts
        .post(png, text.value, (msg) => setStatus(msg))
        .then((result) => {
          setStatus(result.message, result.ok ? 'good' : 'bad');
          if (result.ok) postBtn.textContent = 'Posted ✓';
          if (!result.done) {
            postBtn.disabled = false;
            text.readOnly = false;
            postBtn.textContent = 'Try again';
          }
        })
        .finally(() => (busy = false));
    });
    backBtn.addEventListener('click', () => {
      closed = true;
      opts.back();
    });
  }
}