Deterministic 10Hz sim (power/breaker, spatial heat, uplink bandwidth, train-vs-infer economy), Pixi isometric renderer over MODELBEAST-generated room + sprites, DOM HUD with SlopTube feed, save/load, company naming, WebAudio SFX. Slop thumbnails are genuine diffusion slop; low model tiers publish deranged qwen-7B titles, training buys coherence. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
232 lines
8.5 KiB
TypeScript
232 lines
8.5 KiB
TypeScript
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<number, Sprite>();
|
|
textures = new Map<string, Texture>();
|
|
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<Texture | null> {
|
|
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<number>();
|
|
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) };
|
|
}
|
|
}
|