New 📊 HQ panel (Tab): gross money broken out per stream (slop ads,
API drip, hosting, SaaS, power), compute allocation and constraints
(VRAM/disk/uplink), model line status, and a rule-based WHAT'S NEXT
advisor that surfaces the top three moves for the current state —
the always-available answer to "how do I progress". Left column
restructured: chips flow two-per-row and the tutorial card docks
below the controls, so it can no longer cover the TRAIN slider it
was pointing at. Rates now track ad/API/SaaS revenue separately.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
248 lines
9.9 KiB
TypeScript
248 lines
9.9 KiB
TypeScript
import { BAL } from "./sim/balance";
|
|
import { createState, pushEvent } from "./sim/state";
|
|
import { step, place, canPlace, wire, sell, grant, raiseRound, clapBack, shipGrade, switchHosting, unlockScrape, buyGpu, sellGpu, extinguishFire, moveRoom, spamBlast } from "./sim/sim";
|
|
import { saveState, loadState, clearSave } from "./sim/save";
|
|
import { DEF, isCompute } from "./sim/catalog";
|
|
import { Scene } from "./render/scene";
|
|
import { Hud, applyTheme, currentTheme } from "./ui/hud";
|
|
import { Panels } from "./ui/panels";
|
|
import { Tutorial } from "./ui/tutorial";
|
|
import { sfx } from "./ui/sfx";
|
|
import { unlockGen, unlockModality, setSpec, setDataSource, unlockRnd, buyUpgrade } from "./sim/sim";
|
|
import { DataSource } from "./sim/research";
|
|
|
|
async function boot() {
|
|
applyTheme(currentTheme());
|
|
const el = document.getElementById("app")!;
|
|
const loaded = loadState();
|
|
const state = loaded ?? createState();
|
|
let wiped = false;
|
|
const scene = new Scene();
|
|
await scene.init(el, state.room);
|
|
|
|
// --- input state ---
|
|
let shopSel: string | null = null;
|
|
let wireMode = false;
|
|
let wireSource: number | null = null;
|
|
let mouse = { x: 0, y: 0 };
|
|
|
|
const hud = new Hud({
|
|
onSelectShop: (id) => { shopSel = id; wireMode = false; wireSource = null; hud.setWireActive(false); },
|
|
onToggleOverlay: (w) => { scene.overlays[w] = !scene.overlays[w]; },
|
|
onWireTool: () => {
|
|
wireMode = !wireMode;
|
|
wireSource = null;
|
|
shopSel = null;
|
|
hud.selectedShop = null;
|
|
hud.setWireActive(wireMode);
|
|
},
|
|
onAlloc: (v) => { state.allocTraining = v; },
|
|
onAllocHost: (v) => { state.allocHosting = v; },
|
|
onNewGame: () => { wiped = true; clearSave(); tutorial.reset(); location.reload(); },
|
|
onRaise: () => {
|
|
const err = raiseRound(state);
|
|
if (err) { pushEvent(state, err, "info"); sfx.error(); } else sfx.money();
|
|
},
|
|
onClapback: () => {
|
|
const err = clapBack(state);
|
|
if (err) { pushEvent(state, err, "info"); sfx.error(); }
|
|
},
|
|
onAdSpend: (v) => { state.adSpend = v; },
|
|
onSpam: () => {
|
|
const err = spamBlast(state);
|
|
if (err) { pushEvent(state, err, "info"); sfx.error(); } else sfx.money();
|
|
},
|
|
onMove: (to) => {
|
|
const err = moveRoom(state, to);
|
|
if (err) { pushEvent(state, err, "info"); sfx.error(); }
|
|
else { scene.setRoom(state.room); sfx.tierUp(); saveState(state); }
|
|
},
|
|
});
|
|
hud.bindBreaker(state);
|
|
const panels = new Panels(state);
|
|
const tutorial = new Tutorial();
|
|
const tutChip = hud.root.querySelector("#tut-toggle") as HTMLButtonElement;
|
|
const syncTutChip = () => tutChip.classList.toggle("on", tutorial.enabled);
|
|
tutChip.onclick = () => { tutorial.setEnabled(!tutorial.enabled); syncTutChip(); };
|
|
syncTutChip();
|
|
const labelsChip = hud.root.querySelector("#labels-btn") as HTMLButtonElement;
|
|
const syncLabels = () => labelsChip.classList.toggle("on", scene.showLabels);
|
|
labelsChip.onclick = () => {
|
|
scene.showLabels = !scene.showLabels;
|
|
localStorage.setItem("gigaslop-labels", scene.showLabels ? "1" : "0");
|
|
syncLabels();
|
|
sfx.click();
|
|
};
|
|
syncLabels();
|
|
(hud.root.querySelector("#lab-btn") as HTMLButtonElement).onclick = () => panels.toggle("lab");
|
|
(hud.root.querySelector("#rnd-btn") as HTMLButtonElement).onclick = () => panels.toggle("rnd");
|
|
(hud.root.querySelector("#market-btn") as HTMLButtonElement).onclick = () => panels.toggle("market");
|
|
(hud.root.querySelector("#hq-btn") as HTMLButtonElement).onclick = () => panels.toggle("hq");
|
|
|
|
const cancel = () => {
|
|
shopSel = null; hud.selectedShop = null; hud.lockedCache = "";
|
|
wireMode = false; wireSource = null; hud.setWireActive(false);
|
|
};
|
|
|
|
window.addEventListener("keydown", (e) => {
|
|
if ((e.target as HTMLElement)?.tagName === "INPUT") return;
|
|
if (e.key === "Escape") { cancel(); panels.closeAll(); }
|
|
if (e.key === "w" || e.key === "W") {
|
|
wireMode = !wireMode; wireSource = null; shopSel = null; hud.setWireActive(wireMode);
|
|
}
|
|
if (e.key === "l" || e.key === "L") panels.toggle("lab");
|
|
if (e.key === "r" || e.key === "R") panels.toggle("rnd");
|
|
if (e.key === "m" || e.key === "M") panels.toggle("market");
|
|
if (e.key === "Tab") { e.preventDefault(); panels.toggle("hq"); }
|
|
});
|
|
|
|
scene.app.canvas.addEventListener("pointermove", (e) => {
|
|
mouse = { x: e.clientX, y: e.clientY };
|
|
});
|
|
|
|
const deviceAt = (tx: number, ty: number) =>
|
|
state.devices.find((d) => {
|
|
const def = DEF[d.defId];
|
|
return tx >= d.x && tx < d.x + def.footprint.w && ty >= d.y && ty < d.y + def.footprint.h;
|
|
});
|
|
|
|
scene.app.canvas.addEventListener("pointerdown", (e) => {
|
|
const t = scene.tileAt(e.clientX, e.clientY);
|
|
if (e.button === 2) {
|
|
if (shopSel || wireMode) { cancel(); return; }
|
|
if (t.in) {
|
|
const d = deviceAt(t.x, t.y);
|
|
if (d) { sell(state, d.uid); sfx.sell(); }
|
|
}
|
|
return;
|
|
}
|
|
if (!t.in) return;
|
|
if (extinguishFire(state, t.x, t.y)) { sfx.sell(); return; }
|
|
if (shopSel) {
|
|
const err = canPlace(state, shopSel, t.x, t.y);
|
|
if (err) { pushEvent(state, err, "info"); sfx.error(); return; }
|
|
place(state, shopSel, t.x, t.y);
|
|
sfx.place();
|
|
if (state.cash < DEF[shopSel].cost) { shopSel = null; hud.selectedShop = null; hud.lockedCache = ""; }
|
|
return;
|
|
}
|
|
if (wireMode) {
|
|
const d = deviceAt(t.x, t.y);
|
|
if (!d) return;
|
|
const def = DEF[d.defId];
|
|
if (wireSource === null) {
|
|
if (isCompute(def) || def.switchMbps) {
|
|
wireSource = d.uid;
|
|
sfx.click();
|
|
pushEvent(state, `${def.name} picked — now click a router or switch (green)`, "info");
|
|
} else if (def.uplinkMbps) {
|
|
pushEvent(state, "that's the internet side — pick the PC/rig first (cyan), then this", "info");
|
|
} else {
|
|
pushEvent(state, `${def.name} can't be wired — pick a PC, rig, rack, or switch`, "info");
|
|
}
|
|
} else {
|
|
const err = wire(state, wireSource, d.uid);
|
|
if (err) { pushEvent(state, err, "info"); sfx.error(); } else sfx.wire();
|
|
wireSource = null;
|
|
}
|
|
return;
|
|
}
|
|
// no tool active: click inspects a device, empty floor deselects
|
|
const d = deviceAt(t.x, t.y);
|
|
panels.selectedDevice = d ? d.uid : null;
|
|
panels.render();
|
|
if (d) sfx.click();
|
|
});
|
|
scene.app.canvas.addEventListener("contextmenu", (e) => e.preventDefault());
|
|
|
|
// --- fixed-tick sim pump ---
|
|
// setInterval (not RAF) so the economy keeps running when the tab is hidden;
|
|
// background tabs clamp timers to ~1 Hz, the accumulator catches up (max 2 s).
|
|
let acc = 0;
|
|
let last = performance.now();
|
|
setInterval(() => {
|
|
const now = performance.now();
|
|
acc += Math.min(2, (now - last) / 1000);
|
|
last = now;
|
|
if (document.getElementById("name-modal")) { acc = 0; return; } // clock frozen until incorporated
|
|
while (acc >= BAL.SIM_DT) { step(state); acc -= BAL.SIM_DT; }
|
|
}, 50);
|
|
|
|
// --- render loop ---
|
|
const loop = () => {
|
|
const t = scene.tileAt(mouse.x, mouse.y);
|
|
const ghost = shopSel && t.in
|
|
? { defId: shopSel, x: t.x, y: t.y, ok: !canPlace(state, shopSel, t.x, t.y) }
|
|
: null;
|
|
scene.render(state, ghost, 1 / 60, {
|
|
wireActive: wireMode,
|
|
wireSource,
|
|
ringDefs: tutorial.ringDefs(),
|
|
});
|
|
if (wireMode && wireSource !== null) {
|
|
const src = state.devices.find((d) => d.uid === wireSource);
|
|
scene.highlightWireSource(src ?? null, state);
|
|
}
|
|
requestAnimationFrame(loop);
|
|
};
|
|
|
|
// HUD at 5 Hz; open panels refresh at 1 Hz (innerHTML rebuild resets scroll)
|
|
let hudTicks = 0;
|
|
setInterval(() => {
|
|
hud.update(state);
|
|
tutorial.update(state);
|
|
if (++hudTicks % 5 === 0 && (panels.open || panels.selectedDevice !== null)) panels.render();
|
|
}, 200);
|
|
hud.update(state);
|
|
|
|
if (!loaded) {
|
|
// starter bedroom: your old PC (Dumpster inside) and router, not yet wired
|
|
grant(state, "desk", 5, 7);
|
|
grant(state, "desk_pc", 7, 7, ["gfx1060"]);
|
|
grant(state, "router", 2, 1);
|
|
grant(state, "ssd_shoebox", 0, 7);
|
|
const names = await hud.showNameModal();
|
|
state.company = names.company;
|
|
state.modelName = names.model;
|
|
document.title = `${state.company} — GigaSlop`;
|
|
pushEvent(state, `${state.company} is live. ${state.modelName} 1 awaits its slop. Wire the PC to the router (W).`, "info");
|
|
saveState(state);
|
|
} else {
|
|
document.title = `${state.company} — GigaSlop`;
|
|
}
|
|
requestAnimationFrame(loop);
|
|
|
|
// autosave (suppressed after a New Game wipe so the wipe actually sticks)
|
|
setInterval(() => { if (!wiped) saveState(state); }, 10_000);
|
|
window.addEventListener("beforeunload", () => { if (!wiped) saveState(state); });
|
|
document.addEventListener("visibilitychange", () => {
|
|
if (document.visibilityState === "hidden" && !wiped) saveState(state);
|
|
});
|
|
|
|
// debug/automation handle
|
|
(window as never as Record<string, unknown>).__giga = {
|
|
state, scene, place: (id: string, x: number, y: number) => place(state, id, x, y),
|
|
wire: (a: number, b: number) => wire(state, a, b),
|
|
stepN: (n: number) => { for (let i = 0; i < n; i++) step(state); },
|
|
newGame: () => { wiped = true; clearSave(); location.reload(); },
|
|
raise: () => raiseRound(state),
|
|
clap: () => clapBack(state),
|
|
panels,
|
|
gen: () => unlockGen(state),
|
|
modality: (id: string) => unlockModality(state, id),
|
|
spec: (id: string) => setSpec(state, id),
|
|
data: (k: DataSource) => setDataSource(state, k),
|
|
rnd: (id: string) => unlockRnd(state, id),
|
|
upg: (uid: number, id: string) => buyUpgrade(state, uid, id),
|
|
ship: (g: "mini" | "pro" | "max") => shipGrade(state, g),
|
|
host: (id: number | null) => switchHosting(state, id),
|
|
scrape: (id: string) => unlockScrape(state, id),
|
|
gpu: (uid: number, id: string) => buyGpu(state, uid, id),
|
|
sellGpu: (uid: number, slot: number) => sellGpu(state, uid, slot),
|
|
move: (to: string) => { const e = moveRoom(state, to); if (!e) scene.setRoom(state.room); return e; },
|
|
spam: () => spamBlast(state),
|
|
};
|
|
}
|
|
|
|
boot();
|