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.
// 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 · <kbd>Space</kbd> shoot · <kbd>E</kbd> lasso · <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 ? ` · <span class="star" aria-hidden="true">★</span> West tamed ${data.wins}×` : ''}</p>` : '';
let html = '';
if (kind === 'title') {
html = `
<p class="wanted">WANTED</p>
<p class="sub">Runaway pickles · 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>★</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…</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();
});
}
}
