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 { MODEL_NAME_POOL, VERSION_NAMES } from "../sim/research";
import { MOVES, ROOMS } from "../sim/rooms";
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;
onAllocHost: (hostShare: number) => void;
onNewGame: () => void;
onRaise: () => void;
onClapback: () => void;
onAdSpend: (perSec: number) => void;
onSpam: () => void;
onMove: (to: string) => 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);
}
export function randomModelName(): string {
return MODEL_NAME_POOL[Math.floor(Math.random() * MODEL_NAME_POOL.length)];
}
const fmt = (n: number, d = 2) =>
n >= 1e6 ? (n / 1e6).toFixed(1) + "M" : n >= 1e3 ? (n / 1e3).toFixed(1) + "K" : n.toFixed(d);
export const THEMES = [
{ id: "midnight", name: "Midnight" },
{ id: "contrast", name: "Hi-Contrast" },
{ id: "paper", name: "Paper" },
{ id: "crt", name: "CRT" },
];
export function applyTheme(id: string) {
document.documentElement.dataset.theme = THEMES.some((t) => t.id === id) ? id : "midnight";
localStorage.setItem("gigaslop-theme", document.documentElement.dataset.theme!);
}
export function currentTheme(): string {
return localStorage.getItem("gigaslop-theme") ?? "midnight";
}
export class Hud {
root: HTMLElement;
cb: HudCallbacks;
selectedShop: string | null = null;
lastEventCount = 0;
lockedCache = "";
lastRoom = "bedroom";
constructor(cb: HudCallbacks) {
this.cb = cb;
this.root = document.createElement("div");
this.root.id = "hud";
this.root.innerHTML = `
SLOPCO$0
NET/S+$0.00
HOTTEST24Β°C
SLOP RATE0 tok/s
VIEWS0
SUBS0
UPLINK0/0 Mbps
HOSTING$0/s
VRAM0GB
HARDWARE STORE
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();
};
const themeBtn = this.root.querySelector("#theme") as HTMLButtonElement;
themeBtn.onclick = () => {
const i = THEMES.findIndex((t) => t.id === currentTheme());
const next = THEMES[(i + 1) % THEMES.length];
applyTheme(next.id);
themeBtn.textContent = `π¨ ${next.name}`;
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);
};
const allochost = this.root.querySelector("#allochost") as HTMLInputElement;
allochost.oninput = () => {
(this.root.querySelector("#hostpct") as HTMLElement).textContent = allochost.value;
cb.onAllocHost(Number(allochost.value) / 100);
};
const adspend = this.root.querySelector("#adspend") as HTMLInputElement;
adspend.oninput = () => {
(this.root.querySelector("#adspct") as HTMLElement).textContent = adspend.value;
cb.onAdSpend(Number(adspend.value));
};
(this.root.querySelector("#spam") as HTMLButtonElement).onclick = () => cb.onSpam();
(this.root.querySelector("#move-hq") as HTMLButtonElement).onclick = () => {
const mv = MOVES.find((m) => m.from === this.lastRoom);
if (mv && confirm(`${ROOMS[mv.to].name}: ${mv.blurb} β $${mv.cost}?`)) cb.onMove(mv.to);
};
}
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, .shop-cat").forEach((e) => e.remove());
let lastCat = "";
for (const it of items) {
if (it.def.cat !== lastCat) {
lastCat = it.def.cat;
const h = document.createElement("div");
h.className = "shop-cat";
h.textContent = lastCat.toUpperCase();
shop.appendChild(h);
}
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 + model line on a fresh game */
showNameModal(): Promise<{ company: string; model: string }> {
return new Promise((resolve) => {
const m = document.createElement("div");
m.id = "name-modal";
m.innerHTML = `
`;
this.root.appendChild(m);
const input = m.querySelector("#cname") as HTMLInputElement;
const minput = m.querySelector("#mname") as HTMLInputElement;
(m.querySelector("#reroll") as HTMLButtonElement).onclick = () => {
input.value = randomCompanyName();
sfx.click();
};
(m.querySelector("#mreroll") as HTMLButtonElement).onclick = () => {
minput.value = randomModelName();
sfx.click();
};
const done = () => {
m.remove();
sfx.tierUp();
resolve({
company: input.value.trim() || "SlopCo",
model: minput.value.trim() || "SlopGPT",
});
};
(m.querySelector("#cstart") as HTMLButtonElement).onclick = done;
for (const el of [input, minput])
el.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);
q("#subs").textContent = fmt(s.subs, 0) + (s.rates.saasLatency ? " β " : "");
(q("#subs") as HTMLElement).style.color =
s.rates.saasLatency ? "var(--red)" : s.rates.subsNet > 0.01 ? "var(--green)" : "";
(q("#subs") as HTMLElement).title =
s.rates.saasLatency ? "LATENCY β subscribers churning; free up inference compute" : "SaaS subscribers";
// 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)}`;
}
// spam cooldown + move availability
const spam = q("#spam");
const cd = Math.max(0, 60 - (s.t - s.lastSpamT));
spam.textContent = cd > 0 ? `π§ Spam Blast (${Math.ceil(cd)}s)` : `π§ Spam Blast ($${40 * (s.round + 1)})`;
spam.classList.toggle("on", cd <= 0);
this.lastRoom = s.room;
const mv = MOVES.find((m) => m.from === s.room);
(q("#move-hq") as HTMLElement).style.display =
mv && s.earnedTotal >= mv.unlockAt ? "block" : "none";
if (mv) q("#move-hq").textContent = `π ${ROOMS[mv.to].name} ($${fmt(mv.cost, 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 = `${s.modelName} ${VERSION_NAMES[s.tier]}` + (s.collapsed ? " π" : "");
q("#host").textContent = "$" + fmt(s.rates.hostIncome, 3) + "/s";
(q("#host") as HTMLElement).style.color = s.rates.hostIncome > 0 ? "var(--green)" : "";
q("#vram").textContent = s.rates.vramNeed > 0
? `${Math.round(s.rates.vramHave)}/${s.rates.vramNeed}GB`
: `${Math.round(s.rates.vramHave)}GB`;
(q("#vram") as HTMLElement).style.color =
s.rates.vramNeed > 0 && s.rates.vramHave < s.rates.vramNeed ? "var(--red)" : "";
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);
}
}