gigaslop/src/ui/hud.ts
type-two cdb8fa59f8 Theme system: Midnight, Hi-Contrast, Paper, CRT
Palette fully variable-driven (chips, cards, bars, inputs, toasts,
tutorial) with a cycling theme chip, persisted choice, and per-theme
canvas brightness lift so the scene itself reads better. Hi-Contrast
bumps the most-read font sizes; Paper is a true light mode; CRT is
phosphor green with a glow, because of course it is.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 21:58:53 +10:00

384 lines
19 KiB
TypeScript

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 = `
<div id="topbar" class="panel">
<div class="stat"><span class="label" id="company">SLOPCO</span><span class="val" id="cash">$0</span></div>
<div class="stat"><span class="label">NET/S</span><span class="val" id="income">+$0.00</span></div>
<div class="stat"><span class="label">POWER</span><span class="val" id="watts">0W</span><div class="bar"><div id="wattbar"></div></div></div>
<div class="stat"><span class="label">HOTTEST</span><span class="val" id="temp">24°C</span></div>
<div class="stat"><span class="label">SLOP RATE</span><span class="val" id="tok">0 tok/s</span></div>
<div class="stat"><span class="label">VIEWS</span><span class="val" id="views">0</span></div>
<div class="stat"><span class="label">SUBS</span><span class="val" id="subs">0</span></div>
<div class="stat"><span class="label">UPLINK</span><span class="val" id="net">0/0 Mbps</span></div>
<div class="stat"><span class="label">HOSTING</span><span class="val" id="host">$0/s</span></div>
<div class="stat"><span class="label">VRAM</span><span class="val" id="vram">0GB</span></div>
<div class="stat"><span class="label">MODEL</span><span class="val" id="tier">SlopLM-1B</span><div class="bar train"><div id="trainbar"></div></div></div>
<div class="stat" title="Platform spam-filter wariness. Publishing raises it, views drop as it climbs. Train a new model tier for relief."><span class="label">ALGO HEAT</span><span class="val" id="adapt">0%</span><div class="bar"><div id="adaptbar"></div></div></div>
<button id="breaker-reset">RESET BREAKER</button>
</div>
<div id="fxrow"></div>
<div id="controls" class="panel">
<button class="chip" data-ov="heat">🌡 Heat</button>
<button class="chip" data-ov="power">⚡ Power</button>
<button class="chip" data-ov="data">📶 Data</button>
<button class="chip" data-ov="grid">▦ Grid</button>
<button class="chip" id="wire-tool">🔌 Wire (W)</button>
<button class="chip" id="lab-btn">🧪 Model Lab (L)</button>
<button class="chip" id="rnd-btn">🏢 R&D (R)</button>
<button class="chip" id="market-btn">📡 Market (M)</button>
<button class="chip" id="mute">${isMuted() ? "🔇 Muted" : "🔊 Sound"}</button>
<button class="chip" id="theme">🎨 ${THEMES.find((t) => t.id === currentTheme())?.name ?? "Midnight"}</button>
<button class="chip" id="tut-toggle">❓ Tutorial</button>
<button class="chip" id="new-game">🗑 New Game</button>
<button class="chip" id="raise" style="display:none">💰 Raise</button>
<div id="mandate" style="display:none">
<div id="mtext"></div>
<div class="bar" style="width:100%"><div id="mbar"></div></div>
</div>
<button id="clap" style="display:none"></button>
<div id="alloc-row">
<span class="label" style="color:var(--dim);font-size:9px">TRAIN <b id="allocpct">25</b>%</span>
<input id="alloc" type="range" min="0" max="90" value="25" />
<span class="label" style="color:var(--dim);font-size:9px">HOST <b id="hostpct">0</b>% · rest = slop</span>
<input id="allochost" type="range" min="0" max="90" value="0" />
<span class="label" style="color:var(--dim);font-size:9px">ADS $<b id="adspct">0</b>/s</span>
<input id="adspend" type="range" min="0" max="20" value="0" />
</div>
<button class="chip" id="spam">📧 Spam Blast</button>
<button class="chip" id="move-hq" style="display:none">🚚 Move HQ</button>
</div>
<div id="shop" class="panel"><h3>HARDWARE STORE</h3></div>
<div id="feed" class="panel"><h3><b>▶</b> SLOPTUBE STUDIO</h3><div id="vids"></div></div>
<div id="hint">click store item, place in room · W: wire PC→router · right-click: sell · Esc: cancel</div>
<div id="toasts"></div>`;
document.body.appendChild(this.root);
this.root.querySelectorAll<HTMLButtonElement>("[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 = `<img src="assets/gen/${it.def.sprite}_cut.png" onerror="this.style.visibility='hidden'"/>
<div><div class="nm">${it.def.name}</div>
${it.locked ? `<div class="lock">🔒 earn $${it.def.unlockAt}</div>` : `<div class="cost">$${it.def.cost}</div>`}</div>`;
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 = `
<div class="panel" id="name-box">
<h2>GIGASLOP</h2>
<p>Every empire of garbage needs a name.</p>
<div class="row">
<input id="cname" maxlength="28" value="${randomCompanyName()}" spellcheck="false"/>
<button id="reroll" title="reroll">🎲</button>
</div>
<p style="margin-top:10px">…and so does the model you'll inflict on the world.</p>
<div class="row">
<input id="mname" maxlength="20" value="${randomModelName()}" spellcheck="false"/>
<button id="mreroll" title="reroll">🎲</button>
</div>
<button id="cstart">INCORPORATE →</button>
</div>`;
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 `<span class="fxbadge ${good ? "up" : "down"}">${e.label} · ${Math.max(0, e.expires - s.t).toFixed(0)}s</span>`;
}).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) => `
<div class="vid">
<img src="assets/slop/slop_${String(v.thumb).padStart(2, "0")}.png"/>
<div><div class="t">${(TITLES as string[])[v.title] ?? "slop"}</div>
<div class="v">👁 ${fmt(v.views, 0)} views ${v.viral > 6 ? '<b class="viral">🔥VIRAL</b>' : ""}${v.dead ? " · faded" : ""}</div></div>
</div>`).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);
}
}