feat(pixel-office): add gateway-driven server alarm lamp and rack sprite

This commit is contained in:
xmanrui
2026-03-04 23:46:27 +08:00
parent 7406ba39fd
commit 5213ee33bb
6 changed files with 162 additions and 2 deletions
+20 -1
View File
@@ -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<ContributionData | null>(null)
const photographRef = useRef<HTMLImageElement | null>(null)
const gatewayRef = useRef<{ port: number; token?: string; host?: string }>({ port: 18789 })
const gatewayHealthyRef = useRef<boolean>(true)
const providersRef = useRef<Array<{ id: string; api: string; models: Array<{ id: string; name: string; contextWindow?: number }>; 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
+54 -1
View File
@@ -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)
@@ -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' },
@@ -236,6 +236,12 @@ const RIGHT_WALL_STOOLS: ReadonlyArray<PlacedFurniture> = [
{ 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<PlacedFurniture> = {
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 },
+75
View File
@@ -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'
+1
View File
@@ -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',