import { Application, Assets, Container, Graphics, Sprite, Texture } from "pixi.js"; import { BAL } from "../sim/balance"; import { DEF } from "../sim/catalog"; import { SimState, Device } from "../sim/state"; import { TILE_W, TILE_H, ROOM_IMG, tileToScreen, screenToTile, diamond, lPath, inBounds } from "./iso"; export interface Overlays { heat: boolean; power: boolean; data: boolean; grid: boolean } export class Scene { app!: Application; world = new Container(); // scaled/centered room space deviceLayer = new Container(); cableGfx = new Graphics(); overlayGfx = new Graphics(); ghostSprite: Sprite | null = null; sprites = new Map(); textures = new Map(); slopTextures: Texture[] = []; overlays: Overlays = { heat: true, power: false, data: false, grid: false }; pulsePhase = 0; outlet = tileToScreen(0, 5); // wall outlet anchor (left wall) async init(el: HTMLElement) { this.app = new Application(); await this.app.init({ background: "#07070c", resizeTo: window, antialias: true }); el.appendChild(this.app.canvas); // load textures with graceful fallback const names = ["desk", "desk_pc", "router", "desk_fan", "gpu_rig", "window_ac", "power_strip", "server_rack_mini"]; const room = await this.tryLoad("/assets/gen/room.png"); for (const n of names) { const t = await this.tryLoad(`/assets/gen/${n}_cut.png`); if (t) this.textures.set(n, t); } for (let i = 0; i < 10; i++) { const t = await this.tryLoad(`/assets/slop/slop_${String(i).padStart(2, "0")}.png`); if (t) this.slopTextures.push(t); } if (room) { const bg = new Sprite(room); this.world.addChild(bg); } this.deviceLayer.sortableChildren = true; this.world.addChild(this.cableGfx, this.deviceLayer, this.overlayGfx); this.app.stage.addChild(this.world); this.layout(); window.addEventListener("resize", () => this.layout()); } private async tryLoad(url: string): Promise { try { return await Assets.load(url); } catch { console.warn("missing asset", url); return null; } } layout() { const w = this.app.renderer.width, h = this.app.renderer.height; const s = Math.min(w / ROOM_IMG.w, h / ROOM_IMG.h) * 0.98; this.world.scale.set(s); this.world.position.set((w - ROOM_IMG.w * s) / 2, (h - ROOM_IMG.h * s) / 2); } /** pointer event → room-image space */ toRoomSpace(clientX: number, clientY: number) { const rect = this.app.canvas.getBoundingClientRect(); const gx = ((clientX - rect.left) / rect.width) * this.app.renderer.width; const gy = ((clientY - rect.top) / rect.height) * this.app.renderer.height; return { x: (gx - this.world.position.x) / this.world.scale.x, y: (gy - this.world.position.y) / this.world.scale.y, }; } spriteFor(d: Device): Sprite { let sp = this.sprites.get(d.uid); if (!sp) { const def = DEF[d.defId]; const tex = this.textures.get(def.sprite); sp = tex ? new Sprite(tex) : this.placeholderSprite(); sp.anchor.set(0.5, 0.82); this.sprites.set(d.uid, sp); this.deviceLayer.addChild(sp); } return sp; } private placeholderSprite(): Sprite { const g = new Graphics().rect(-40, -80, 80, 80).fill({ color: 0x8b5cf6, alpha: 0.7 }); const tex = this.app.renderer.generateTexture(g); const sp = new Sprite(tex); return sp; } scaleFor(defId: string, sp: Sprite): number { const def = DEF[defId]; const targetW = (def.footprint.w + def.footprint.h) * 0.5 * TILE_W * 1.35; return targetW / sp.texture.width; } render(s: SimState, ghost: { defId: string; x: number; y: number; ok: boolean } | null, dt: number) { this.pulsePhase = (this.pulsePhase + dt * 1.6) % 1; // --- devices --- const seen = new Set(); for (const d of s.devices) { seen.add(d.uid); const def = DEF[d.defId]; 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 + TILE_H / 2); if (def.wallOnly) sp.position.y -= 55; // hang on the wall (window height) 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 sp.tint = !d.powered ? 0x777788 : d.perf < 1 ? 0xffb0a0 : 0xffffff; sp.alpha = 1; } for (const [uid, sp] of this.sprites) if (!seen.has(uid)) { sp.destroy(); this.sprites.delete(uid); } // --- ghost --- if (ghost) { const def = DEF[ghost.defId]; const tex = this.textures.get(def.sprite); if (!this.ghostSprite) { this.ghostSprite = tex ? new Sprite(tex) : this.placeholderSprite(); this.ghostSprite.anchor.set(0.5, 0.82); this.deviceLayer.addChild(this.ghostSprite); } const g = this.ghostSprite; const c = tileToScreen(ghost.x + (def.footprint.w - 1) / 2, ghost.y + (def.footprint.h - 1) / 2); g.position.set(c.x, c.y + TILE_H / 2); if (def.wallOnly) g.position.y -= 55; g.scale.set(this.scaleFor(ghost.defId, g)); g.alpha = 0.55; g.tint = ghost.ok ? 0x88ff88 : 0xff6666; g.zIndex = 999; g.visible = true; } else if (this.ghostSprite) { this.ghostSprite.visible = false; } this.drawCables(s); this.drawOverlays(s, ghost); } private drawCables(s: SimState) { const g = this.cableGfx; g.clear(); for (const d of s.devices) { if (d.wiredTo === null) continue; const rt = s.devices.find((r) => r.uid === d.wiredTo); if (!rt) continue; const path = lPath(d.x, d.y, rt.x, rt.y).map((p) => { const c = tileToScreen(p.x, p.y); return { x: c.x, y: c.y + TILE_H * 0.45 }; }); if (path.length < 2) continue; const live = d.powered && rt.powered; g.moveTo(path[0].x, path[0].y); for (const p of path.slice(1)) g.lineTo(p.x, p.y); g.stroke({ width: 4, color: 0x0e2a33, alpha: 0.9 }); g.moveTo(path[0].x, path[0].y); for (const p of path.slice(1)) g.lineTo(p.x, p.y); g.stroke({ width: 1.6, color: live ? 0x22d3ee : 0x2a4a55, alpha: live ? 0.95 : 0.6 }); // data pulse dots if (live) { const total = path.length - 1; for (let k = 0; k < 2; k++) { const t = ((this.pulsePhase + k * 0.5) % 1) * total; const i = Math.min(Math.floor(t), total - 1); const f = t - i; const px = path[i].x + (path[i + 1].x - path[i].x) * f; const py = path[i].y + (path[i + 1].y - path[i].y) * f; g.circle(px, py, 3).fill({ color: 0x67e8f9, alpha: 0.9 }); } } } // power lines to outlet (overlay only) if (this.overlays.power) { for (const d of s.devices) { const def = DEF[d.defId]; if (!def.wattsLoad || !d.powered) continue; const c = tileToScreen(d.x, d.y); g.moveTo(c.x, c.y + TILE_H * 0.45); g.lineTo(this.outlet.x - TILE_W / 2, this.outlet.y); g.stroke({ width: 1.4, color: 0xf59e0b, alpha: 0.5 }); } g.circle(this.outlet.x - TILE_W / 2, this.outlet.y, 6).fill({ color: 0xf59e0b, alpha: 0.9 }); } } private drawOverlays(s: SimState, ghost: { defId: string } | null) { const g = this.overlayGfx; g.clear(); // heat: subtle always, strong when toggled const boost = this.overlays.heat ? 1 : 0.4; for (let x = 0; x < BAL.ROOM_W; x++) for (let y = 0; y < BAL.ROOM_H; y++) { const h = s.heat[y * BAL.ROOM_W + x]; if (h < 1.5) continue; const a = Math.min(0.55, (h / 70) * boost); g.poly(diamond(x, y)).fill({ color: h > 55 ? 0xff2200 : 0xff7700, alpha: a }); } if (this.overlays.grid || ghost) { for (let x = 0; x < BAL.ROOM_W; x++) for (let y = 0; y < BAL.ROOM_H; y++) g.poly(diamond(x, y)).stroke({ width: 1.5, color: 0xc4b5fd, alpha: 0.5 }); } } highlightWireSource(d: Device | null, s: SimState) { // draw a pulsing ring on the wire-tool source device via overlayGfx (called after drawOverlays) if (!d) return; const c = tileToScreen(d.x, d.y); const r = 18 + Math.sin(this.pulsePhase * Math.PI * 2) * 3; this.overlayGfx.circle(c.x, c.y + TILE_H * 0.3, r).stroke({ width: 2, color: 0x22d3ee, alpha: 0.9 }); void s; } tileAt(clientX: number, clientY: number) { const p = this.toRoomSpace(clientX, clientY); const t = screenToTile(p.x, p.y); return { ...t, in: inBounds(t.x, t.y) }; } }