import "./hud.css"; import { BAL } from "../sim/balance"; import { SimState } from "../sim/state"; import { shopList, resetBreaker } from "../sim/sim"; import { sfx, setMuted, isMuted } from "./sfx"; import TITLES from "../data/sloptitles.json"; export interface HudCallbacks { onSelectShop: (defId: string | null) => void; onToggleOverlay: (which: "heat" | "power" | "data" | "grid") => void; onWireTool: () => void; onAlloc: (trainShare: number) => void; onNewGame: () => void; onRaise: () => void; onClapback: () => void; } const NAME_A = ["Slop", "Grift", "Chungus", "Synergy", "Vibe", "Goon", "Brainrot", "Yeet", "Chud", "Slud", "Content", "Engagement"]; const NAME_B = ["Works", "Labs", "Dynamics", "Compute", "Industries", "Cloud", "Farms", "Global", "Forge", "Mines", "Refinery"]; const NAME_C = ["", " Inc.", ".ai", " LLC", " Unlimited", " & Sons"]; export function randomCompanyName(): string { const pick = (a: string[]) => a[Math.floor(Math.random() * a.length)]; return pick(NAME_A) + pick(NAME_B) + pick(NAME_C); } const fmt = (n: number, d = 2) => n >= 1e6 ? (n / 1e6).toFixed(1) + "M" : n >= 1e3 ? (n / 1e3).toFixed(1) + "K" : n.toFixed(d); export class Hud { root: HTMLElement; cb: HudCallbacks; selectedShop: string | null = null; lastEventCount = 0; lockedCache = ""; constructor(cb: HudCallbacks) { this.cb = cb; this.root = document.createElement("div"); this.root.id = "hud"; this.root.innerHTML = `
SLOPCO$0
NET/S+$0.00
POWER0W
HOTTEST24Β°C
SLOP RATE0 tok/s
VIEWS0
UPLINK0/0 Mbps
MODELSlopLM-1B
ALGO HEAT0%
TRAIN 25% / INFER

HARDWARE STORE

β–Ά SLOPTUBE STUDIO

click store item, place in room · W: wire PC→router · right-click: sell · Esc: cancel
`; document.body.appendChild(this.root); this.root.querySelectorAll("[data-ov]").forEach((b) => { b.onclick = () => { cb.onToggleOverlay(b.dataset.ov as never); b.classList.toggle("on"); }; if (b.dataset.ov === "heat") b.classList.add("on"); }); (this.root.querySelector("#wire-tool") as HTMLButtonElement).onclick = () => cb.onWireTool(); const muteBtn = this.root.querySelector("#mute") as HTMLButtonElement; muteBtn.onclick = () => { setMuted(!isMuted()); muteBtn.textContent = isMuted() ? "πŸ”‡ Muted" : "πŸ”Š Sound"; if (!isMuted()) sfx.click(); }; (this.root.querySelector("#new-game") as HTMLButtonElement).onclick = () => { if (confirm("Torch the whole company and start over?")) cb.onNewGame(); }; (this.root.querySelector("#raise") as HTMLButtonElement).onclick = () => cb.onRaise(); (this.root.querySelector("#clap") as HTMLButtonElement).onclick = () => cb.onClapback(); const alloc = this.root.querySelector("#alloc") as HTMLInputElement; alloc.oninput = () => { (this.root.querySelector("#allocpct") as HTMLElement).textContent = alloc.value; cb.onAlloc(Number(alloc.value) / 100); }; } bindBreaker(state: SimState) { (this.root.querySelector("#breaker-reset") as HTMLButtonElement).onclick = () => resetBreaker(state); } setWireActive(on: boolean) { (this.root.querySelector("#wire-tool") as HTMLElement).classList.toggle("on", on); } refreshShop(s: SimState) { const items = shopList(s); const cacheKey = items.map((i) => `${i.locked}${i.affordable}`).join() + this.selectedShop; if (cacheKey === this.lockedCache) return; this.lockedCache = cacheKey; const shop = this.root.querySelector("#shop") as HTMLElement; shop.querySelectorAll(".shop-item").forEach((e) => e.remove()); for (const it of items) { const el = document.createElement("div"); el.className = "shop-item" + (it.locked || !it.affordable ? " dim" : "") + (this.selectedShop === it.def.id ? " selected" : ""); el.innerHTML = `
${it.def.name}
${it.locked ? `
πŸ”’ earn $${it.def.unlockAt}
` : `
$${it.def.cost}
`}
`; el.title = it.def.desc; if (!it.locked) el.onclick = () => { this.selectedShop = this.selectedShop === it.def.id ? null : it.def.id; this.cb.onSelectShop(this.selectedShop); this.lockedCache = ""; }; shop.appendChild(el); } } /** modal for naming the company on a fresh game; resolves with the chosen name */ showNameModal(): Promise { return new Promise((resolve) => { const m = document.createElement("div"); m.id = "name-modal"; m.innerHTML = `

GIGASLOP

Every empire of garbage needs a name.

`; this.root.appendChild(m); const input = m.querySelector("#cname") as HTMLInputElement; (m.querySelector("#reroll") as HTMLButtonElement).onclick = () => { input.value = randomCompanyName(); sfx.click(); }; const done = () => { const name = input.value.trim() || "SlopCo"; m.remove(); sfx.tierUp(); resolve(name); }; (m.querySelector("#cstart") as HTMLButtonElement).onclick = done; input.onkeydown = (e) => { if (e.key === "Enter") done(); e.stopPropagation(); }; input.focus(); input.select(); }); } private prevVideoCount = -1; update(s: SimState) { const q = (id: string) => this.root.querySelector(id) as HTMLElement; q("#company").textContent = s.company.toUpperCase(); q("#cash").textContent = "$" + fmt(s.cash); const net = s.rates.income - s.rates.powerCost; q("#income").textContent = (net >= 0 ? "+$" : "-$") + fmt(Math.abs(net), 3); q("#income").classList.toggle("neg", net < 0); q("#watts").textContent = `${Math.round(s.rates.watts)}/${s.rates.breakerWatts}W`; q("#watts").classList.toggle("hot", s.rates.watts > s.rates.breakerWatts * 0.85); (q("#wattbar") as HTMLElement).style.width = Math.min(100, (s.rates.watts / s.rates.breakerWatts) * 100) + "%"; (q("#wattbar") as HTMLElement).style.background = s.rates.watts > s.rates.breakerWatts * 0.85 ? "var(--red)" : "var(--amber)"; q("#temp").textContent = Math.round(s.rates.maxTemp) + "Β°C"; q("#temp").classList.toggle("hot", s.rates.maxTemp > BAL.THROTTLE_C); q("#tok").textContent = fmt(s.rates.tokEff, 1) + " tok/s"; q("#net").textContent = `${fmt(s.rates.demandMbps, 0)}/${fmt(s.rates.uplinkMbps, 0)} Mbps`; (q("#net") as HTMLElement).style.color = s.rates.dataMult < 1 ? "var(--red)" : ""; q("#views").textContent = fmt(s.totalViews, 0); // VC raise chip / mandate progress const raise = q("#raise"), mandate = q("#mandate"); const offer = BAL.ROUNDS[s.round]; if (s.mandate) { raise.style.display = "none"; mandate.style.display = "block"; const got = s.totalViews - s.mandate.startViews; const left = Math.max(0, s.mandate.deadline - s.t); q("#mtext").textContent = `${s.mandate.name}: ${fmt(got, 0)}/${fmt(s.mandate.target, 0)} πŸ‘ Β· ${Math.floor(left / 60)}:${String(Math.floor(left % 60)).padStart(2, "0")}`; (q("#mbar") as HTMLElement).style.width = Math.min(100, (got / s.mandate.target) * 100) + "%"; (q("#mbar") as HTMLElement).style.background = left < 60 ? "var(--red)" : "var(--green)"; (q("#mtext") as HTMLElement).style.color = left < 60 ? "var(--red)" : "var(--text)"; } else { mandate.style.display = "none"; raise.style.display = offer ? "block" : "none"; if (offer) raise.textContent = `πŸ’° Raise ${offer.name}: +$${fmt(offer.cash, 0)}`; } // clap-back window const clap = q("#clap"); if (s.clapback) { clap.style.display = "block"; const secs = Math.max(0, s.clapback.expires - s.t); clap.textContent = `😀 ${s.clapback.label} ($${s.clapback.cost}) β€” ${secs.toFixed(0)}s`; } else { clap.style.display = "none"; } // rival effect badges const fxrow = q("#fxrow"); fxrow.innerHTML = s.effects.map((e) => { const good = (e.viewMult ?? 1) > 1; return `${e.label} Β· ${Math.max(0, e.expires - s.t).toFixed(0)}s`; }).join(""); const ad = s.rates.adapt; q("#adapt").textContent = Math.round(ad * 100) + "%"; q("#adapt").classList.toggle("hot", ad > 0.5); (q("#adaptbar") as HTMLElement).style.width = Math.round(ad * 100) + "%"; (q("#adaptbar") as HTMLElement).style.background = ad > 0.5 ? "var(--red)" : ad > 0.25 ? "var(--amber)" : "var(--green)"; q("#tier").textContent = BAL.TIERS[s.tier].name; const nt = BAL.TIERS[s.tier + 1]; (q("#trainbar") as HTMLElement).style.width = nt ? Math.min(100, (s.trainTokens / nt.tokens!) * 100) + "%" : "100%"; (q("#breaker-reset") as HTMLElement).style.display = s.breakerTripped ? "block" : "none"; // feed: newest 6 videos const vids = q("#vids"); const latest = s.videos.slice(-6).reverse(); vids.innerHTML = latest.map((v) => `
${(TITLES as string[])[v.title] ?? "slop"}
πŸ‘ ${fmt(v.views, 0)} views ${v.viral > 6 ? 'πŸ”₯VIRAL' : ""}${v.dead ? " Β· faded" : ""}
`).join(""); // publish blips; first update after boot swallows the loaded-save backlog silently if (this.prevVideoCount === -1) this.lastEventCount = s.events.length; if (this.prevVideoCount >= 0 && s.videos.length > this.prevVideoCount) sfx.publish(); this.prevVideoCount = s.videos.length; // toasts + event sounds const toasts = q("#toasts"); for (const ev of s.events.slice(this.lastEventCount)) { if (ev.kind === "bad") sfx.breaker(); else if (ev.kind === "rival") sfx.rival(); else if (ev.kind === "good") ev.msg.includes("TIER UP") ? sfx.tierUp() : ev.msg.includes("$") ? sfx.money() : sfx.viral(); if (ev.msg.includes("VIRAL")) { const f = document.createElement("div"); f.id = "flash"; document.body.appendChild(f); setTimeout(() => f.remove(), 700); } if (ev.kind === "buy") continue; const t = document.createElement("div"); t.className = "toast " + ev.kind; t.textContent = ev.msg; toasts.appendChild(t); setTimeout(() => t.remove(), 4200); } this.lastEventCount = s.events.length; this.refreshShop(s); } }