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:
parent
e245ebf85d
commit
095c889ec7
@ -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) => {
|
||||
|
||||
@ -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 }[] = [];
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user