Blueprint view (V) and item codex inspector

Two viewpoints: the painted isometric room, and a top-down Blueprint
mode — schematic navy grid, square tiles, right-angle cable runs —
the planning view for dense builds. Projection lives behind one
viewMode switch in iso.ts so every overlay (heat, grid, rings, wire
affordances) works in both. Codex: ℹ on any shop item or a click on
any GPU card thumbnail opens a full-art inspector with flavor text
and complete stats (slots, throughput, storage, cooling, fire
immunity, reliability). GLB/3D per item can slot into the same modal
later via a MeshGod pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-08-02 11:58:50 +10:00
parent e245ebf85d
commit 095c889ec7
6 changed files with 151 additions and 8 deletions

View File

@ -10,6 +10,7 @@ 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";
import { viewMode } from "./render/iso";
async function boot() {
applyTheme(currentTheme());
@ -57,6 +58,7 @@ async function boot() {
if (err) { pushEvent(state, err, "info"); sfx.error(); }
else { scene.setRoom(state.room); sfx.tierUp(); saveState(state); }
},
onInfo: (defId) => panels.showCodex("device", defId),
});
hud.bindBreaker(state);
const panels = new Panels(state);
@ -78,6 +80,11 @@ async function boot() {
(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 viewBtn = hud.root.querySelector("#view-btn") as HTMLButtonElement;
const syncView = () => { viewBtn.textContent = viewMode() === "top" ? "👁 Blueprint (V)" : "👁 Iso (V)"; };
const toggleView = () => { scene.setView(viewMode() === "iso" ? "top" : "iso"); syncView(); sfx.click(); };
viewBtn.onclick = toggleView;
syncView();
const cancel = () => {
shopSel = null; hud.selectedShop = null; hud.lockedCache = "";
@ -94,6 +101,7 @@ async function boot() {
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"); }
if (e.key === "v" || e.key === "V") toggleView();
});
scene.app.canvas.addEventListener("pointermove", (e) => {

View File

@ -2,6 +2,8 @@ import { ROOMS, RoomDef } from "../sim/rooms";
// Active room geometry — scene calls setRoomGeom on init and on HQ moves.
let G: RoomDef = ROOMS.bedroom;
export type ViewMode = "iso" | "top";
let VIEW: ViewMode = (localStorage.getItem("gigaslop-view") as ViewMode) || "iso";
export function setRoomGeom(r: RoomDef) {
G = r;
@ -9,8 +11,29 @@ export function setRoomGeom(r: RoomDef) {
export function geom(): RoomDef {
return G;
}
export function viewMode(): ViewMode {
return VIEW;
}
export function setViewMode(v: ViewMode) {
VIEW = v;
localStorage.setItem("gigaslop-view", v);
}
/** blueprint (top-down) square size + grid origin, centered in the image frame */
function topGeom() {
const t = Math.min((G.imgW * 0.86) / G.w, (G.imgH * 0.86) / G.h);
return {
t,
ox: (G.imgW - G.w * t) / 2 + t / 2,
oy: (G.imgH - G.h * t) / 2 + t / 2,
};
}
export function tileToScreen(x: number, y: number) {
if (VIEW === "top") {
const { t, ox, oy } = topGeom();
return { x: ox + x * t, y: oy + y * t };
}
return {
x: G.origin.x + ((x - y) * G.tileW) / 2,
y: G.origin.y + ((x + y) * G.tileH) / 2,
@ -18,6 +41,10 @@ export function tileToScreen(x: number, y: number) {
}
export function screenToTile(sx: number, sy: number) {
if (VIEW === "top") {
const { t, ox, oy } = topGeom();
return { x: Math.round((sx - ox) / t), y: Math.round((sy - oy) / t) };
}
const dx = sx - G.origin.x;
const dy = sy - G.origin.y;
const fx = dx / (G.tileW / 2), fy = dy / (G.tileH / 2);
@ -28,8 +55,14 @@ export function inBounds(x: number, y: number) {
return x >= 0 && y >= 0 && x < G.w && y < G.h;
}
/** tile cell outline — diamond in iso, square in blueprint (all overlays reuse this) */
export function diamond(x: number, y: number): number[] {
const c = tileToScreen(x, y);
if (VIEW === "top") {
const { t } = topGeom();
const h = t / 2;
return [c.x - h, c.y - h, c.x + h, c.y - h, c.x + h, c.y + h, c.x - h, c.y + h];
}
return [
c.x, c.y - G.tileH / 2,
c.x + G.tileW / 2, c.y,
@ -38,6 +71,11 @@ export function diamond(x: number, y: number): number[] {
];
}
/** effective tile width for sprite scaling in the active view */
export function tileSpan(): number {
return VIEW === "top" ? topGeom().t : G.tileW;
}
// L-shaped tile path (x first, then y) for cable routing
export function lPath(ax: number, ay: number, bx: number, by: number): { x: number; y: number }[] {
const pts: { x: number; y: number }[] = [];

View File

@ -1,7 +1,7 @@
import { Application, Assets, Container, Graphics, Sprite, Text, Texture } from "pixi.js";
import { CATALOG, DEF, isCompute } from "../sim/catalog";
import { SimState, Device } from "../sim/state";
import { geom, setRoomGeom, tileToScreen, screenToTile, diamond, lPath, inBounds } from "./iso";
import { geom, setRoomGeom, tileToScreen, screenToTile, diamond, lPath, inBounds, viewMode, setViewMode, tileSpan, ViewMode } from "./iso";
import { ROOMS } from "../sim/rooms";
export interface Overlays { heat: boolean; power: boolean; data: boolean; grid: boolean }
@ -16,6 +16,7 @@ export class Scene {
app!: Application;
world = new Container(); // scaled/centered room space
deviceLayer = new Container();
floorGfx = new Graphics(); // blueprint-view schematic floor
cableGfx = new Graphics();
overlayGfx = new Graphics();
ghostSprite: Sprite | null = null;
@ -52,7 +53,7 @@ export class Scene {
this.bgSprite = new Sprite();
this.world.addChild(this.bgSprite);
this.deviceLayer.sortableChildren = true;
this.world.addChild(this.cableGfx, this.deviceLayer, this.labelLayer, this.overlayGfx);
this.world.addChild(this.floorGfx, this.cableGfx, this.deviceLayer, this.labelLayer, this.overlayGfx);
this.app.stage.addChild(this.world);
await this.setRoom(roomId);
window.addEventListener("resize", () => this.layout());
@ -63,6 +64,7 @@ export class Scene {
setRoomGeom(room);
const tex = await this.tryLoad(room.image);
if (tex && this.bgSprite) this.bgSprite.texture = tex;
if (this.bgSprite) this.bgSprite.visible = viewMode() === "iso";
this.layout();
}
@ -116,10 +118,32 @@ export class Scene {
scaleFor(defId: string, sp: Sprite): number {
const def = DEF[defId];
const targetW = (def.footprint.w + def.footprint.h) * 0.5 * geom().tileW * 1.35;
const mult = viewMode() === "top" ? 0.95 : 1.35;
const targetW = (def.footprint.w + def.footprint.h) * 0.5 * tileSpan() * mult;
return targetW / sp.texture.width;
}
setView(v: ViewMode) {
setViewMode(v);
if (this.bgSprite) this.bgSprite.visible = v === "iso";
}
/** schematic floor for blueprint view */
private drawFloor() {
const g = this.floorGfx;
g.clear();
if (viewMode() !== "top") return;
const room = geom();
const tl = tileToScreen(0, 0), br = tileToScreen(room.w - 1, room.h - 1);
const t = tileSpan(), h = t / 2;
g.rect(tl.x - h - 6, tl.y - h - 6, br.x - tl.x + t + 12, br.y - tl.y + t + 12)
.fill({ color: 0x0d1526, alpha: 0.96 })
.stroke({ width: 3, color: 0x2b4a7a, alpha: 0.9 });
for (let x = 0; x < room.w; x++)
for (let y = 0; y < room.h; y++)
g.poly(diamond(x, y)).stroke({ width: 1, color: 0x24406b, alpha: 0.55 });
}
render(s: SimState, ghost: { defId: string; x: number; y: number; ok: boolean } | null, dt: number,
assist?: Assist) {
this.pulsePhase = (this.pulsePhase + dt * 1.6) % 1;
@ -132,8 +156,8 @@ export class Scene {
const sp = this.spriteFor(d);
// center of footprint
const c = tileToScreen(d.x + (def.footprint.w - 1) / 2, d.y + (def.footprint.h - 1) / 2);
sp.position.set(c.x, c.y + geom().tileH / 2);
if (def.wallOnly) sp.position.y -= 55; // hang on the wall (window height)
sp.position.set(c.x, c.y + (viewMode() === "top" ? tileSpan() * 0.35 : geom().tileH / 2));
if (def.wallOnly && viewMode() === "iso") sp.position.y -= 55; // hang on the wall
sp.scale.set(this.scaleFor(d.defId, sp));
sp.zIndex = d.x + d.y + (def.wallOnly ? -100 : 0);
// state tinting: unpowered = dim, throttled = red-ish
@ -182,6 +206,7 @@ export class Scene {
this.ghostSprite.visible = false;
}
this.drawFloor();
this.drawCables(s);
this.drawOverlays(s, ghost, assist);
}

View File

@ -208,6 +208,21 @@ body { background: var(--bg); }
#adapt.hot { color: var(--red); }
/* codex item inspector */
.codex-back { position: fixed; inset: 0; background: rgba(4, 4, 10, 0.7); z-index: 60;
display: flex; align-items: center; justify-content: center; pointer-events: auto; }
.codex { width: min(400px, 92vw); max-height: 88vh; overflow-y: auto; padding: 14px 16px; }
.codex-art { display: block; max-width: 80%; max-height: 36vh; margin: 6px auto 10px; object-fit: contain;
filter: drop-shadow(0 8px 24px rgba(0, 0, 0, 0.5)); }
.codex-desc { font-size: 12px; color: var(--soft); font-style: italic; margin-bottom: 10px; line-height: 1.4; }
.codex .node { display: flex; justify-content: space-between; align-items: baseline; }
.codex .node small { margin-top: 0; text-align: right; }
.shop-item .info-btn { margin-left: auto; background: none; border: 1px solid var(--edge);
color: var(--dim); border-radius: 50%; width: 20px; height: 20px; cursor: pointer;
font-size: 10px; line-height: 1; flex: none; }
.shop-item .info-btn:hover { color: var(--cyan); border-color: var(--cyan); }
.gpu-row img { cursor: zoom-in; }
/* tutorial pointing */
.tut-glow { outline: 2px solid var(--amber); outline-offset: 2px; border-radius: 8px;
animation: tutpulse 0.9s infinite alternate; }

View File

@ -19,6 +19,7 @@ export interface HudCallbacks {
onAdSpend: (perSec: number) => void;
onSpam: () => void;
onMove: (to: string) => void;
onInfo: (defId: string) => void;
}
const NAME_A = ["Slop", "Grift", "Chungus", "Synergy", "Vibe", "Goon", "Brainrot", "Yeet", "Chud", "Slud", "Content", "Engagement"];
@ -94,6 +95,7 @@ export class Hud {
<button class="chip" id="rnd-btn">🏢 R&D (R)</button>
<button class="chip" id="market-btn">📡 Market (M)</button>
<button class="chip" id="hq-btn">📊 HQ (Tab)</button>
<button class="chip" id="view-btn">👁 View (V)</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="labels-btn">🏷 Labels</button>
@ -199,8 +201,13 @@ export class Hud {
(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>`;
${it.locked ? `<div class="lock">🔒 earn $${it.def.unlockAt}</div>` : `<div class="cost">$${it.def.cost}</div>`}</div>
<button class="info-btn" title="inspect"></button>`;
el.title = it.def.desc;
(el.querySelector(".info-btn") as HTMLButtonElement).onclick = (ev) => {
ev.stopPropagation();
this.cb.onInfo(it.def.id);
};
if (!it.locked) el.onclick = () => {
this.selectedShop = this.selectedShop === it.def.id ? null : it.def.id;
this.cb.onSelectShop(this.selectedShop);

View File

@ -21,6 +21,7 @@ export class Panels {
state: SimState;
open: "lab" | "rnd" | "market" | "hq" | null = null;
selectedDevice: number | null = null;
codex: { kind: "device" | "gpu"; id: string } | null = null;
root: HTMLElement;
constructor(state: SimState) {
@ -40,6 +41,13 @@ export class Panels {
closeAll() {
this.open = null;
this.selectedDevice = null;
this.codex = null;
this.render();
}
showCodex(kind: "device" | "gpu", id: string) {
this.codex = { kind, id };
sfx.click();
this.render();
}
@ -51,6 +59,8 @@ export class Panels {
let err: string | null = null;
switch (cmd) {
case "close": this.closeAll(); return;
case "codex": this.codex = { kind: el.dataset.kind as "device" | "gpu", id: arg! }; sfx.click(); this.render(); return;
case "codex-close": this.codex = null; sfx.click(); this.render(); return;
case "gen": err = unlockGen(s); break;
case "modality": err = unlockModality(s, arg!); break;
case "spec": err = setSpec(s, arg!); break;
@ -86,9 +96,49 @@ export class Panels {
if (d) html += this.deviceHtml(s, d);
else this.selectedDevice = null;
}
if (this.codex) html += this.codexHtml();
this.root.innerHTML = html;
}
private codexHtml(): string {
const { kind, id } = this.codex!;
const rows: [string, string][] = [];
let name = "", desc = "", sprite = "", cost = 0;
if (kind === "gpu") {
const g = GPU_DEF[id];
if (!g) return "";
name = g.name; desc = g.desc; sprite = g.sprite; cost = g.cost;
rows.push(["Tokens", `${g.tok} tok/s`], ["Power", `${g.watts}W`], ["VRAM", `${g.vram}GB`],
["Heat", `${g.heat}/s`]);
if (g.flaky) rows.push(["Reliability", `dies ~${(g.flaky * 100).toFixed(1)}%/min under load`]);
if (g.rackOnly) rows.push(["Fits in", "racks & tanks only"]);
} else {
const d = DEF[id];
if (!d) return "";
name = d.name; desc = d.desc; sprite = d.sprite; cost = d.cost;
rows.push(["Category", d.cat], ["Footprint", `${d.footprint.w}x${d.footprint.h} tiles`],
["Power", `${d.wattsIdle}W idle / ${d.wattsLoad}W load`]);
if (d.gpuSlots) rows.push(["GPU slots", `${d.gpuSlots}${d.rackClass ? " (takes datacenter cards)" : ""}`]);
if (d.fireImmune) rows.push(["Fire", "immune (submerged)"]);
if (d.uplinkMbps) rows.push(["Uplink", `${d.uplinkMbps} Mbps · ${d.ports ?? 4} ports`]);
if (d.switchMbps) rows.push(["Switch", `${d.switchMbps} Mbps through · ${d.ports ?? 4} ports`]);
if (d.storageGB) rows.push(["Storage", `${d.storageGB} GB`]);
if (d.coolRate) rows.push(["Cooling", `${d.coolRate} heat/s, radius 4`]);
if (d.fan) rows.push(["Airflow", "spreads heat away (3x3)"]);
if (d.circuitBonus) rows.push(["Circuit", `+${d.circuitBonus}W breaker capacity`]);
if (d.burnRate) rows.push(["Burns", `$${d.burnRate}/s for +${(d.hypeMult ?? 0) * 100}% views`]);
if (d.heatOut) rows.push(["Heat", `${d.heatOut}/s at load`]);
if (d.wallOnly) rows.push(["Placement", "wall only"]);
}
return `<div class="codex-back" data-cmd="codex-close"><div class="panel codex" data-cmd="noop">
<div class="mhead"><h2>${name}</h2><span class="bank">$${fmt(cost)}</span>
<button class="mclose" data-cmd="codex-close"></button></div>
<img class="codex-art" src="assets/gen/${sprite}_cut.png" alt=""/>
<div class="codex-desc">${desc}</div>
${rows.map(([k, v]) => `<div class="node"><span>${k}</span><small>${v}</small></div>`).join("")}
</div></div>`;
}
private labHtml(s: SimState): string {
const gen = BAL.TIERS[s.tier];
const next = BAL.TIERS[s.tier + 1];
@ -289,7 +339,7 @@ export class Panels {
${def.gpuSlots ? `<h4>GPU SLOTS (${d.gpus.length}/${def.gpuSlots})</h4>
${d.gpus.map((g, i) => {
const gd = GPU_DEF[g];
return `<div class="node owned gpu-row"><img src="assets/gen/${gd.sprite}_cut.png"/>
return `<div class="node owned gpu-row"><img src="assets/gen/${gd.sprite}_cut.png" data-cmd="codex" data-kind="gpu" data-arg="${g}" title="inspect"/>
<span>${gd.name}<small>${gd.tok} tok/s · ${gd.watts}W · ${gd.vram}GB</small></span>
<button class="mclose" data-cmd="sellgpu" data-arg="${i}" title="sell +$${Math.round(gd.cost * 0.5)}">💸</button></div>`;
}).join("")}
@ -297,7 +347,7 @@ export class Panels {
? `<h4>INSTALL CARD</h4>` + GPUS
.filter((g) => (g.unlockAt ?? 0) <= s.earnedTotal && (!g.rackOnly || def.rackClass))
.map((g) => `<button class="node buy gpu-row ${s.cash >= g.cost ? "" : "dim"}" data-cmd="gpu" data-arg="${g.id}">
<img src="assets/gen/${g.sprite}_cut.png"/>
<img src="assets/gen/${g.sprite}_cut.png" data-cmd="codex" data-kind="gpu" data-arg="${g.id}" title="inspect"/>
<span>${g.name}<small>${g.tok} tok/s · ${g.watts}W · ${g.vram}GB · heat ${g.heat} · $${fmt(g.cost)}</small></span></button>`).join("")
: ""}` : ""}
${compute ? `<h4>SOCKETS (${d.upgrades.length}/${SOCKET_SLOTS})</h4>