From 095c889ec717a2d3ae5322afd2a69f42bb4c7ee9 Mon Sep 17 00:00:00 2001 From: type-two Date: Sun, 2 Aug 2026 11:58:50 +1000 Subject: [PATCH] Blueprint view (V) and item codex inspector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/main.ts | 8 +++++++ src/render/iso.ts | 38 +++++++++++++++++++++++++++++++ src/render/scene.ts | 35 ++++++++++++++++++++++++----- src/ui/hud.css | 15 +++++++++++++ src/ui/hud.ts | 9 +++++++- src/ui/panels.ts | 54 +++++++++++++++++++++++++++++++++++++++++++-- 6 files changed, 151 insertions(+), 8 deletions(-) diff --git a/src/main.ts b/src/main.ts index 6c34478..50fc13d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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) => { diff --git a/src/render/iso.ts b/src/render/iso.ts index 94d6367..b70f9a3 100644 --- a/src/render/iso.ts +++ b/src/render/iso.ts @@ -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 }[] = []; diff --git a/src/render/scene.ts b/src/render/scene.ts index 1b280bd..8f7d424 100644 --- a/src/render/scene.ts +++ b/src/render/scene.ts @@ -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); } diff --git a/src/ui/hud.css b/src/ui/hud.css index e041c38..b694748 100644 --- a/src/ui/hud.css +++ b/src/ui/hud.css @@ -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; } diff --git a/src/ui/hud.ts b/src/ui/hud.ts index 9e4f296..86b3e82 100644 --- a/src/ui/hud.ts +++ b/src/ui/hud.ts @@ -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 { + @@ -199,8 +201,13 @@ export class Hud { (this.selectedShop === it.def.id ? " selected" : ""); el.innerHTML = `
${it.def.name}
- ${it.locked ? `
๐Ÿ”’ earn $${it.def.unlockAt}
` : `
$${it.def.cost}
`}
`; + ${it.locked ? `
๐Ÿ”’ earn $${it.def.unlockAt}
` : `
$${it.def.cost}
`} + `; 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); diff --git a/src/ui/panels.ts b/src/ui/panels.ts index c336f65..91545fd 100644 --- a/src/ui/panels.ts +++ b/src/ui/panels.ts @@ -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 `
+

${name}

$${fmt(cost)} +
+ +
${desc}
+ ${rows.map(([k, v]) => `
${k}${v}
`).join("")} +
`; + } + 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 ? `

GPU SLOTS (${d.gpus.length}/${def.gpuSlots})

${d.gpus.map((g, i) => { const gd = GPU_DEF[g]; - return `
+ return `
${gd.name}${gd.tok} tok/s ยท ${gd.watts}W ยท ${gd.vram}GB
`; }).join("")} @@ -297,7 +347,7 @@ export class Panels { ? `

INSTALL CARD

` + GPUS .filter((g) => (g.unlockAt ?? 0) <= s.earnedTotal && (!g.rackOnly || def.rackClass)) .map((g) => ``).join("") : ""}` : ""} ${compute ? `

SOCKETS (${d.upgrades.length}/${SOCKET_SLOTS})