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>
384 lines
19 KiB
TypeScript
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);
|
|
}
|
|
}
|