diff --git a/app/pixel-office/page.tsx b/app/pixel-office/page.tsx index b1ca16b..c9f9124 100644 --- a/app/pixel-office/page.tsx +++ b/app/pixel-office/page.tsx @@ -173,6 +173,7 @@ const CODE_SNIPPET_LIFETIME_SEC = 5.5 const FLOATING_TICK_INTERVAL_DESKTOP_MS = 48 const FLOATING_TICK_INTERVAL_MOBILE_MS = 32 const AGENT_ACTIVITY_POLL_INTERVAL_MS = 1000 +const GATEWAY_HEALTH_POLL_INTERVAL_MS = 10000 let cachedOfficeState: OfficeState | null = null let cachedEditorState: EditorState | null = null @@ -206,6 +207,7 @@ export default function PixelOfficePage() { const contributionsRef = useRef(null) const photographRef = useRef(null) const gatewayRef = useRef<{ port: number; token?: string; host?: string }>({ port: 18789 }) + const gatewayHealthyRef = useRef(true) const providersRef = useRef; usedBy: Array<{ id: string; emoji: string; name: string }> }>>([]) const [isEditMode, setIsEditMode] = useState(cachedIsEditMode) const [soundOn, setSoundOn] = useState(true) @@ -425,7 +427,8 @@ export default function PixelOfficePage() { { selectedAgentId: null, hoveredAgentId, hoveredTile: null, seats: office.seats, characters: office.characters }, editorRender, office.layout.tileColors, office.layout.cols, office.layout.rows, undefined, - contributionsRef.current ?? undefined, photographRef.current ?? undefined) + contributionsRef.current ?? undefined, photographRef.current ?? undefined, + gatewayHealthyRef.current) // Collect photo comment positions for DOM rendering const zoom = zoomRef.current @@ -641,6 +644,22 @@ export default function PixelOfficePage() { return () => clearInterval(interval) }, []) + // Poll gateway health for server alarm lamp in Pixel Office + useEffect(() => { + const fetchGatewayHealth = async () => { + try { + const res = await fetch('/api/gateway-health', { cache: 'no-store' }) + const data = await res.json() + gatewayHealthyRef.current = !!data?.ok + } catch { + gatewayHealthyRef.current = false + } + } + fetchGatewayHealth() + const interval = setInterval(fetchGatewayHealth, GATEWAY_HEALTH_POLL_INTERVAL_MS) + return () => clearInterval(interval) + }, []) + // ── Editor helpers ────────────────────────────────────────── const applyEdit = useCallback((newLayout: OfficeLayout) => { const office = officeRef.current diff --git a/lib/pixel-office/engine/renderer.ts b/lib/pixel-office/engine/renderer.ts index bedf3c8..3f406c1 100644 --- a/lib/pixel-office/engine/renderer.ts +++ b/lib/pixel-office/engine/renderer.ts @@ -229,6 +229,7 @@ export function renderScene( hoveredAgentId: number | null, contributions?: ContributionData, photograph?: HTMLImageElement, + gatewayHealthy?: boolean, ): void { const drawables: ZDrawable[] = [] const laptopSizeScale = 0.7 @@ -309,6 +310,57 @@ export function renderScene( c.drawImage(cached, fx, fy) }, }) + + // Server alarm beacon (top of the server rack in the lounge left wall). + if (f.uid === 'server-b-left') { + const healthy = gatewayHealthy !== false + const now = Date.now() + const healthyPulse = (Math.sin(now / 900) + 1) / 2 + const unhealthyPulse = (Math.sin(now / 120) + 1) / 2 + const blinkAlpha = healthy + ? (0.55 + healthyPulse * 0.4) // slow breathing blink + : (0.15 + unhealthyPulse * 0.85) // fast urgent blink + drawables.push({ + zY: f.zY + 0.15, + draw: (c) => { + const lampX = Math.round(fx + 15 * zoom) + const lampTopY = Math.round(fy + 1 * zoom) + const lampW = Math.max(3, Math.round(3.6 * zoom)) + const lampH = Math.max(2, Math.round(2.2 * zoom)) + const stemW = Math.max(1, Math.round(1.1 * zoom)) + const stemH = Math.max(1, Math.round(1.4 * zoom)) + const baseW = Math.max(2, Math.round(2.6 * zoom)) + const baseH = Math.max(1, Math.round(1.1 * zoom)) + const lampLeft = Math.round(lampX - lampW / 2) + const stemLeft = Math.round(lampX - stemW / 2) + const stemTop = lampTopY + lampH + const baseLeft = Math.round(lampX - baseW / 2) + const baseTop = stemTop + stemH + c.save() + c.globalAlpha = blinkAlpha + // Lamp cover (pixel warning light, not a sphere) + c.fillStyle = '#2B2F45' + c.fillRect(lampLeft - 1, lampTopY - 1, lampW + 2, lampH + 2) + c.fillStyle = healthy ? '#63E46F' : '#F25F5C' + c.fillRect(lampLeft, lampTopY, lampW, lampH) + // Lamp stem + base + c.fillStyle = '#3A425E' + c.fillRect(stemLeft, stemTop, stemW, stemH) + c.fillRect(baseLeft, baseTop, baseW, baseH) + // Pixel glow bands to keep a lamp-like look (avoid spherical aura) + const glowOuter = Math.max(8, Math.round(8.4 * zoom)) + const glowMid = Math.max(5, Math.round(5.8 * zoom)) + const glowInner = Math.max(3, Math.round(3.4 * zoom)) + c.fillStyle = healthy ? 'rgba(99,228,111,0.14)' : 'rgba(242,95,92,0.2)' + c.fillRect(lampX - glowOuter, lampTopY - glowOuter, glowOuter * 2, glowOuter * 2) + c.fillStyle = healthy ? 'rgba(99,228,111,0.2)' : 'rgba(242,95,92,0.28)' + c.fillRect(lampX - glowMid, lampTopY - glowMid, glowMid * 2, glowMid * 2) + c.fillStyle = healthy ? 'rgba(99,228,111,0.28)' : 'rgba(242,95,92,0.35)' + c.fillRect(lampX - glowInner, lampTopY - glowInner, glowInner * 2, glowInner * 2) + c.restore() + }, + }) + } } } @@ -923,6 +975,7 @@ export function renderFrame( bugs?: BugEntity[], contributions?: ContributionData, photograph?: HTMLImageElement, + gatewayHealthy?: boolean, ): { offsetX: number; offsetY: number } { // Clear ctx.clearRect(0, 0, canvasWidth, canvasHeight) @@ -960,7 +1013,7 @@ export function renderFrame( // Draw walls + furniture + characters (z-sorted) const selectedId = selection?.selectedAgentId ?? null const hoveredId = selection?.hoveredAgentId ?? null - renderScene(ctx, allFurniture, characters, offsetX, offsetY, zoom, selectedId, hoveredId, contributions, photograph) + renderScene(ctx, allFurniture, characters, offsetX, offsetY, zoom, selectedId, hoveredId, contributions, photograph, gatewayHealthy) // Speech bubbles (always on top of characters) renderBubbles(ctx, characters, offsetX, offsetY, zoom) diff --git a/lib/pixel-office/layout/furnitureCatalog.ts b/lib/pixel-office/layout/furnitureCatalog.ts index 9eb66ea..5a4d7f3 100644 --- a/lib/pixel-office/layout/furnitureCatalog.ts +++ b/lib/pixel-office/layout/furnitureCatalog.ts @@ -11,6 +11,7 @@ import { PC_BACK_SPRITE, CAMERA_SPRITE, LAMP_SPRITE, + SERVER_RACK_SPRITE, } from '../sprites/spriteData' import { TS_TABLE_WOOD_SM_VERTICAL, TS_TABLE_WOOD_SM_HORIZONTAL, @@ -62,6 +63,7 @@ export const FURNITURE_CATALOG: CatalogEntryWithCategory[] = [ { type: FurnitureType.SOFA, label: 'Sofa', footprintW: 2, footprintH: 1, sprite: [], isDesk: false, category: 'decor', emoji: '🛋️', emojiScale: 5 }, { type: FurnitureType.COFFEE, label: 'Coffee', footprintW: 1, footprintH: 1, sprite: [], isDesk: false, category: 'decor', emoji: '☕', canPlaceOnSurfaces: true, emojiScale: 0.67 }, { type: FurnitureType.LAMP, label: 'Lamp', footprintW: 1, footprintH: 1, sprite: LAMP_SPRITE, isDesk: false, category: 'decor' }, + { type: FurnitureType.SERVER_RACK, label: 'Server Rack', footprintW: 2, footprintH: 2, sprite: SERVER_RACK_SPRITE, isDesk: false, category: 'electronics' }, // ── Tileset — Desks ── { type: FurnitureType.TABLE_WOOD_SM_VERTICAL, label: 'Wood Table Vertical', footprintW: 1, footprintH: 2, sprite: TS_TABLE_WOOD_SM_VERTICAL, isDesk: true, category: 'desks' }, diff --git a/lib/pixel-office/layout/layoutSerializer.ts b/lib/pixel-office/layout/layoutSerializer.ts index 831d497..67030a1 100644 --- a/lib/pixel-office/layout/layoutSerializer.ts +++ b/lib/pixel-office/layout/layoutSerializer.ts @@ -236,6 +236,12 @@ const RIGHT_WALL_STOOLS: ReadonlyArray = [ { uid: 'stool-r7', type: FurnitureType.BENCH, col: 17, row: 6 }, { uid: 'stool-r8', type: FurnitureType.BENCH, col: 17, row: 7.5 }, ] +const LEFT_WALL_SERVER: Readonly = { + uid: 'server-b-left', + type: FurnitureType.SERVER_RACK, + col: 1, + row: 12, +} function shouldRemoveRightOfficeLegacyItems(item: PlacedFurniture): boolean { if (item.uid.startsWith('stool-r')) return true @@ -253,6 +259,9 @@ function normalizeRightOfficeFurniture(furniture: PlacedFurniture[]): PlacedFurn const exists = next.some((item) => item.uid === stool.uid) if (!exists) next.push({ ...stool }) } + if (!next.some((item) => item.uid === LEFT_WALL_SERVER.uid)) { + next.push({ ...LEFT_WALL_SERVER }) + } return next } @@ -357,6 +366,7 @@ export function createDefaultLayout(): OfficeLayout { // ── Bottom lounge / break area ── { uid: 'fridge-b', type: FurnitureType.FRIDGE, col: 1, row: 9.5 }, + { ...LEFT_WALL_SERVER }, { uid: 'water-cooler-b', type: FurnitureType.WATER_COOLER, col: 8, row: 9.5 }, { uid: 'deco-b', type: FurnitureType.DECO_3, col: 9, row: 9.5 }, { uid: 'plant-b1', type: FurnitureType.PLANT, col: 1, row: 15 }, diff --git a/lib/pixel-office/sprites/spriteData.ts b/lib/pixel-office/sprites/spriteData.ts index 8ec32fa..5c325ae 100644 --- a/lib/pixel-office/sprites/spriteData.ts +++ b/lib/pixel-office/sprites/spriteData.ts @@ -167,6 +167,81 @@ export const COOLER_SPRITE: SpriteData = (() => { ] })() +/** Server rack: 32x32 (2 tiles wide, 2 tiles tall) */ +export const SERVER_RACK_SPRITE: SpriteData = (() => { + // Polished to match reference more closely: + // dual towers, vented top cap, layered front panels, dense cyan/green LED rows. + const BG = _ + const FRAME = '#2C324B' + const EDGE = '#4B547E' + const TOP = '#626A94' + const PANEL = '#1B2238' + const SLOT = '#0E1425' + const SLOT_DIV = '#222B45' + const CYAN = '#27D5FF' + const CYAN_DIM = '#1B6B99' + const GREEN = '#63E46F' + const WHITE = '#DCE6F2' + const BASE = '#11182A' + + const W = 32 + const H = 32 + const rows: string[][] = Array.from({ length: H }, () => Array.from({ length: W }, () => BG)) + + const px = (x: number, y: number, c: string) => { + if (x >= 0 && x < W && y >= 0 && y < H) rows[y][x] = c + } + const fill = (x: number, y: number, w: number, h: number, c: string) => { + for (let yy = y; yy < y + h; yy++) for (let xx = x; xx < x + w; xx++) px(xx, yy, c) + } + + const drawRack = (x0: number) => { + // Outer shell + fill(x0, 3, 14, 28, FRAME) + fill(x0 + 1, 4, 12, 25, EDGE) + fill(x0 + 2, 5, 10, 23, PANEL) + + // Top cap with vents + tiny status pixel + fill(x0 + 2, 4, 10, 4, TOP) + fill(x0 + 3, 5, 2, 1, SLOT_DIV) + fill(x0 + 6, 5, 2, 1, SLOT_DIV) + fill(x0 + 9, 5, 2, 1, SLOT_DIV) + fill(x0 + 3, 6, 2, 1, SLOT) + fill(x0 + 6, 6, 2, 1, SLOT) + fill(x0 + 9, 6, 2, 1, SLOT) + px(x0 + 3, 4, WHITE) + + // Rack units (8 rows) + let y = 9 + for (let u = 0; u < 8; u++) { + fill(x0 + 2, y, 10, 2, SLOT) + fill(x0 + 2, y, 10, 1, SLOT_DIV) // divider strip + // LED bars + px(x0 + 3, y + 1, CYAN) + px(x0 + 4, y + 1, CYAN) + px(x0 + 5, y + 1, CYAN_DIM) + px(x0 + 7, y + 1, u % 2 === 0 ? CYAN : CYAN_DIM) + px(x0 + 8, y + 1, u % 3 === 0 ? CYAN : CYAN_DIM) + px(x0 + 10, y + 1, GREEN) + y += 2 + } + + // Bottom I/O + base + fill(x0 + 2, 25, 10, 2, SLOT) + px(x0 + 3, 26, CYAN_DIM) + px(x0 + 5, 26, CYAN_DIM) + px(x0 + 7, 26, CYAN_DIM) + px(x0 + 9, 26, CYAN_DIM) + fill(x0 + 1, 29, 12, 1, BASE) + fill(x0 + 2, 30, 2, 1, BASE) + fill(x0 + 10, 30, 2, 1, BASE) + } + + drawRack(1) + drawRack(15) + return rows +})() + /** Whiteboard: 32x16 (2 tiles wide, 1 tile tall) — hangs on wall */ export const WHITEBOARD_SPRITE: SpriteData = (() => { const F = '#AAAAAA' diff --git a/lib/pixel-office/types.ts b/lib/pixel-office/types.ts index c537672..380bc46 100644 --- a/lib/pixel-office/types.ts +++ b/lib/pixel-office/types.ts @@ -117,6 +117,7 @@ export const FurnitureType = { PAINTING_SMALL_1: 'ts_painting_small_1', PAINTING_SMALL_2: 'ts_painting_small_2', PAINTING_SMALL_3: 'ts_painting_small_3', + SERVER_RACK: 'server_rack', PHONE: 'phone', SOFA: 'sofa', COFFEE: 'coffee',