'use client'; import React from 'react'; import type { OfficeMemberView, OfficeTaskStatus, OfficeView, OfficeZoneId } from '@/lib/office'; import { cn } from '@/lib/utils'; type ZoneLayout = { x: number; y: number; width: number; height: number; }; const WORLD_WIDTH = 400; const WORLD_HEIGHT = 225; const RENDER_SCALE = 2; const SCENE_WIDTH = WORLD_WIDTH * RENDER_SCALE; const SCENE_HEIGHT = WORLD_HEIGHT * RENDER_SCALE; const TILE_SIZE = 16; const MAP_KEY = 'office-winter-v1'; const TILESET_KEY = 'office-winter-tileset'; const MAP_PATH = '/office/maps/office-winter-v1.tmj'; const TILESET_PATH = '/office/tiles/office-winter-tileset.png'; const PIXEL_AGENTS_BASE = '/office/vendor/pixel-agents/assets'; const FURNITURE_ASSETS = { deskFront: { key: 'pixel-agents-desk-front', path: `${PIXEL_AGENTS_BASE}/furniture/DESK/DESK_FRONT.png` }, chairFront: { key: 'pixel-agents-chair-front', path: `${PIXEL_AGENTS_BASE}/furniture/WOODEN_CHAIR/WOODEN_CHAIR_FRONT.png` }, sofaFront: { key: 'pixel-agents-sofa-front', path: `${PIXEL_AGENTS_BASE}/furniture/SOFA/SOFA_FRONT.png` }, tableFront: { key: 'pixel-agents-table-front', path: `${PIXEL_AGENTS_BASE}/furniture/TABLE_FRONT/TABLE_FRONT.png` }, coffeeTable: { key: 'pixel-agents-coffee-table', path: `${PIXEL_AGENTS_BASE}/furniture/COFFEE_TABLE/COFFEE_TABLE.png` }, doubleBookshelf: { key: 'pixel-agents-double-bookshelf', path: `${PIXEL_AGENTS_BASE}/furniture/DOUBLE_BOOKSHELF/DOUBLE_BOOKSHELF.png` }, pcOn: { key: 'pixel-agents-pc-on', path: `${PIXEL_AGENTS_BASE}/furniture/PC/PC_FRONT_ON_1.png` }, whiteboard: { key: 'pixel-agents-whiteboard', path: `${PIXEL_AGENTS_BASE}/furniture/WHITEBOARD/WHITEBOARD.png` }, } as const; const CHARACTER_ASSETS = [ { key: 'pixel-agent-char-0', path: `${PIXEL_AGENTS_BASE}/characters/char_0.png` }, { key: 'pixel-agent-char-1', path: `${PIXEL_AGENTS_BASE}/characters/char_1.png` }, { key: 'pixel-agent-char-2', path: `${PIXEL_AGENTS_BASE}/characters/char_2.png` }, { key: 'pixel-agent-char-3', path: `${PIXEL_AGENTS_BASE}/characters/char_3.png` }, { key: 'pixel-agent-char-4', path: `${PIXEL_AGENTS_BASE}/characters/char_4.png` }, { key: 'pixel-agent-char-5', path: `${PIXEL_AGENTS_BASE}/characters/char_5.png` }, ] as const; const CHARACTER_FRAME = { width: 16, height: 24, columnsPerRow: 7, frontRow: 0, idleColumns: [0, 1, 2], }; const ZONE_LAYOUTS: Record = { reception: { x: 144, y: 28, width: 68, height: 40 }, workspace: { x: 32, y: 28, width: 86, height: 100 }, collab: { x: 152, y: 118, width: 104, height: 62 }, research: { x: 272, y: 28, width: 66, height: 66 }, alert: { x: 284, y: 92, width: 52, height: 54 }, done: { x: 30, y: 154, width: 76, height: 40 }, }; const STATUS_TONES: Record< OfficeTaskStatus, { body: number; outline: number; lamp: number; badge: number; badgeText: string; text: string } > = { queued: { body: 0x8aa0b8, outline: 0xe8f0f8, lamp: 0xcbd5e1, badge: 0x31425b, badgeText: 'Q', text: '#e8f0f8' }, running: { body: 0x90caf9, outline: 0xf5faff, lamp: 0xfff59d, badge: 0x4a5a72, badgeText: 'R', text: '#f5faff' }, waiting: { body: 0xd8c79a, outline: 0xfff7ed, lamp: 0xfde68a, badge: 0x7c6843, badgeText: 'W', text: '#fff7ed' }, blocked: { body: 0xd96c75, outline: 0xffe4e6, lamp: 0xffab91, badge: 0x7b3340, badgeText: '!', text: '#fff1f2' }, done: { body: 0x78c27a, outline: 0xe8f5e9, lamp: 0xc5e1a5, badge: 0x44664b, badgeText: 'D', text: '#f0fdf4' }, error: { body: 0xf36d7d, outline: 0xffd1dc, lamp: 0xffab91, badge: 0x7b2634, badgeText: 'X', text: '#fff1f2' }, cancelled: { body: 0x6b7280, outline: 0xe5e7eb, lamp: 0xd1d5db, badge: 0x374151, badgeText: 'S', text: '#f3f4f6' }, }; function groupMembersByZone(members: OfficeMemberView[]) { const grouped = new Map(); for (const member of members) { const bucket = grouped.get(member.zoneId); if (bucket) { bucket.push(member); } else { grouped.set(member.zoneId, [member]); } } return grouped; } function zoneGridPoints(layout: ZoneLayout, count: number) { if (count <= 0) return []; const innerLeft = layout.x + 12; const innerTop = layout.y + 14; const innerWidth = Math.max(layout.width - 24, 10); const innerHeight = Math.max(layout.height - 20, 10); const columns = count <= 2 ? count : count <= 4 ? 2 : 3; const rows = Math.ceil(count / columns); const points: Array<{ x: number; y: number }> = []; for (let index = 0; index < count; index += 1) { const column = index % columns; const row = Math.floor(index / columns); const x = innerLeft + ((column + 0.5) * innerWidth) / columns; const y = innerTop + ((row + 0.5) * innerHeight) / rows; points.push({ x: Math.round(x), y: Math.round(y) }); } return points; } function buildMemberPositions(office: OfficeView) { const grouped = groupMembersByZone(office.members); const positions = new Map(); for (const zone of office.zones) { const layout = ZONE_LAYOUTS[zone.id]; const members = grouped.get(zone.id) ?? []; const points = zoneGridPoints(layout, members.length); members.forEach((member, index) => { positions.set(member.currentRunId, points[index] ?? { x: layout.x + 20, y: layout.y + 20 }); }); } return positions; } function truncateLabel(value: string, maxLength: number) { if (value.length <= maxLength) return value; return `${value.slice(0, Math.max(1, maxLength - 1))}…`; } function pickCharacterAsset(member: OfficeMemberView, index: number) { if (member.isPrimary) return CHARACTER_ASSETS[0]; return CHARACTER_ASSETS[(index % (CHARACTER_ASSETS.length - 1)) + 1]; } function resolveCharacterPose() { return { row: CHARACTER_FRAME.frontRow, columns: CHARACTER_FRAME.idleColumns, interval: 220, }; } function addFurnitureSprite(scene: any, object: any) { const x = object.x ?? 0; const y = object.y ?? 0; const width = object.width ?? TILE_SIZE; const height = object.height ?? TILE_SIZE; const centerX = x + width / 2; const type = object.type ?? 'anchor'; const addImage = (assetKey: string, px: number, py: number, depth = 20) => scene.add.image(px, py, assetKey).setOrigin(0.5, 1).setDepth(depth); if (type === 'desk-anchor') { const desk = addImage(FURNITURE_ASSETS.deskFront.key, centerX, y + height + 4); const pc = addImage(FURNITURE_ASSETS.pcOn.key, centerX, y + height + 2, 21); return [desk, pc]; } if (type === 'chair-anchor') return [addImage(FURNITURE_ASSETS.chairFront.key, centerX, y + height + 1)]; if (type === 'sofa-anchor') return [addImage(FURNITURE_ASSETS.sofaFront.key, centerX, y + height)]; if (type === 'coffee-anchor') return [addImage(FURNITURE_ASSETS.coffeeTable.key, centerX, y + height)]; if (type === 'meeting-anchor') return [addImage(FURNITURE_ASSETS.tableFront.key, centerX, y + height + 16)]; if (type === 'server-anchor') return [addImage(FURNITURE_ASSETS.doubleBookshelf.key, centerX, y + height)]; if (type === 'archive-anchor') return [addImage(FURNITURE_ASSETS.doubleBookshelf.key, centerX, y + height)]; if (type === 'whiteboard-anchor') return [addImage(FURNITURE_ASSETS.whiteboard.key, centerX, y + height)]; return []; } export function OfficePhaserCanvas({ office, selectedRunId, onRunSelect, className, showMetaBar = true, }: { office: OfficeView; selectedRunId: string | null; onRunSelect: (runId: string) => void; className?: string; showMetaBar?: boolean; }) { const containerRef = React.useRef(null); const selectRef = React.useRef(onRunSelect); React.useEffect(() => { selectRef.current = onRunSelect; }, [onRunSelect]); React.useEffect(() => { let destroyed = false; let game: any = null; async function mountScene() { if (!containerRef.current) return; const PhaserImport = await import('phaser'); const Phaser = (PhaserImport.default ?? PhaserImport) as any; if (destroyed || !containerRef.current) return; const memberPositions = buildMemberPositions(office); class OfficeScene extends Phaser.Scene { preload(this: any) { if (!this.textures.exists(TILESET_KEY)) { this.load.image(TILESET_KEY, TILESET_PATH); } if (!this.cache.tilemap.exists(MAP_KEY)) { this.load.tilemapTiledJSON(MAP_KEY, MAP_PATH); } Object.values(FURNITURE_ASSETS).forEach((asset) => { if (!this.textures.exists(asset.key)) { this.load.image(asset.key, asset.path); } }); CHARACTER_ASSETS.forEach((asset) => { if (!this.textures.exists(asset.key)) { this.load.spritesheet(asset.key, asset.path, { frameWidth: CHARACTER_FRAME.width, frameHeight: CHARACTER_FRAME.height, }); } }); } create(this: any) { this.cameras.main.setBackgroundColor('#1a2433'); this.cameras.main.roundPixels = true; this.cameras.main.setZoom(RENDER_SCALE); this.cameras.main.setBounds(0, 0, WORLD_WIDTH, WORLD_HEIGHT); const map = this.make.tilemap({ key: MAP_KEY }); const tileset = map.addTilesetImage('office-winter-tileset', TILESET_KEY, TILE_SIZE, TILE_SIZE, 0, 0); if (!tileset) { throw new Error('Failed to load office-winter-tileset into tilemap'); } ['bg-floor', 'bg-rug', 'walls', 'windows', 'markers'].forEach((layerName, index) => { const layer = map.createLayer(layerName, tileset, 0, 0); layer?.setDepth(index); }); const frame = this.add.rectangle(0, 0, WORLD_WIDTH, WORLD_HEIGHT, 0x000000, 0).setOrigin(0, 0); frame.setStrokeStyle(4, 0x101827, 1); frame.setDepth(10); const objectLayer = map.getObjectLayer('furniture-anchors'); objectLayer?.objects.forEach((object: any) => { const placed = addFurnitureSprite(this, object); if (placed.length > 0) return; const x = object.x ?? 0; const y = object.y ?? 0; const width = object.width ?? TILE_SIZE; const height = object.height ?? TILE_SIZE; const fallback = this.add.rectangle(x, y, width, height, 0x384b69, 0.18).setOrigin(0, 0); fallback.setStrokeStyle(2, 0x90caf9, 0.9); fallback.setDepth(20); }); const assignmentLines = this.add.graphics(); assignmentLines.setDepth(50); office.assignments.forEach((assignment) => { const from = memberPositions.get(assignment.ownerRunId); if (!from) return; assignment.assigneeRunIds.forEach((assigneeRunId) => { const to = memberPositions.get(assigneeRunId); if (!to) return; assignmentLines.lineStyle(1, 0xffd166, 0.75); assignmentLines.beginPath(); assignmentLines.moveTo(from.x, from.y); assignmentLines.lineTo(to.x, to.y); assignmentLines.strokePath(); assignmentLines.fillStyle(0xffd166, 1); assignmentLines.fillRect(to.x - 1, to.y - 1, 2, 2); }); }); office.members.forEach((member, memberIndex) => { const point = memberPositions.get(member.currentRunId); if (!point) return; const tone = STATUS_TONES[member.status]; const isSelected = selectedRunId === member.currentRunId; const isPrimary = member.isPrimary; const container = this.add.container(point.x, point.y); container.setDepth(60); const clickTarget = this.add.rectangle(0, 0, isPrimary ? 34 : 30, isPrimary ? 36 : 32, 0x000000, 0.001); clickTarget.setInteractive({ useHandCursor: true }); clickTarget.setOrigin(0.5); const shadow = this.add.rectangle(0, 9, isPrimary ? 15 : 13, 4, 0x0f172a, 0.7); shadow.setOrigin(0.5); const characterAsset = pickCharacterAsset(member, memberIndex); const pose = resolveCharacterPose(); let frameIndex = 0; const character = this.add .sprite(0, 4, characterAsset.key, 0) .setDisplaySize(isPrimary ? 24 : 21, isPrimary ? 36 : 32) .setOrigin(0.5, 1); const applyCharacterFrame = () => { const column = pose.columns[frameIndex % pose.columns.length] ?? pose.columns[0] ?? 0; const frame = pose.row * CHARACTER_FRAME.columnsPerRow + column; character.setFrame(frame); frameIndex += 1; }; applyCharacterFrame(); this.time.addEvent({ delay: pose.interval, loop: true, callback: applyCharacterFrame, }); const highlight = this.add.rectangle(0, -9, isPrimary ? 14 : 12, 19, tone.body, 0.12); highlight.setStrokeStyle(isSelected ? 2 : 1, isSelected ? 0xfef3c7 : tone.outline, isSelected ? 1 : 0.7); highlight.setOrigin(0.5); const lamp = this.add.rectangle(isPrimary ? 8 : 7, -9, 3, 3, tone.lamp, 1); lamp.setStrokeStyle(1, 0x101827, 1); lamp.setOrigin(0.5); const badge = this.add.rectangle(0, -14, isPrimary ? 12 : 10, 5, isPrimary ? 0xffd166 : tone.badge, 1); badge.setStrokeStyle(1, 0x101827, 1); badge.setOrigin(0.5); const badgeText = this.add .text(0, -16.5, isPrimary ? 'M' : tone.badgeText, { color: isPrimary ? '#1a2433' : tone.text, fontFamily: '"Courier New", monospace', fontSize: '5px', fontStyle: 'bold', }) .setOrigin(0.5, 0); const name = this.add .text(0, 14, truncateLabel(member.actorName.toUpperCase(), isPrimary ? 10 : 8), { color: '#f5faff', fontFamily: '"Courier New", monospace', fontSize: isPrimary ? '5px' : '4px', fontStyle: 'bold', align: 'center', }) .setOrigin(0.5, 0); const taskLabel = this.add .text(0, 20, truncateLabel((member.stageLabel ?? member.currentTitle).toUpperCase(), 12), { color: '#cbd5e1', fontFamily: '"Courier New", monospace', fontSize: '4px', align: 'center', }) .setOrigin(0.5, 0); container.add([clickTarget, shadow, highlight, badge, badgeText, character, lamp, name, taskLabel]); clickTarget.on('pointerdown', () => { selectRef.current(member.currentRunId); }); clickTarget.on('pointerover', () => { this.tweens.add({ targets: container, scaleX: 1.08, scaleY: 1.08, duration: 90 }); }); clickTarget.on('pointerout', () => { this.tweens.add({ targets: container, scaleX: 1, scaleY: 1, duration: 90 }); }); if (member.status === 'running') { this.tweens.add({ targets: container, y: point.y - 1.5, duration: 500, yoyo: true, repeat: -1, ease: 'Sine.easeInOut', }); this.tweens.add({ targets: lamp, alpha: 0.2, duration: 180, yoyo: true, repeat: -1, }); } if (member.status === 'blocked' || member.status === 'error') { const warn = this.add .text(isPrimary ? 8 : 7, -3, '!', { color: '#fff7ed', fontFamily: '"Courier New", monospace', fontSize: '8px', fontStyle: 'bold', }) .setOrigin(0.5); container.add(warn); this.tweens.add({ targets: warn, alpha: 0.25, duration: 180, yoyo: true, repeat: -1, }); } if (member.status === 'done') { const doneMark = this.add.rectangle(isPrimary ? 7 : 6, 7, 3, 3, 0x78c27a, 1); doneMark.setStrokeStyle(1, 0xf0fdf4, 1); doneMark.setOrigin(0.5); container.add(doneMark); } }); } } game = new Phaser.Game({ type: Phaser.CANVAS, width: SCENE_WIDTH, height: SCENE_HEIGHT, parent: containerRef.current, pixelArt: true, antialias: false, roundPixels: true, backgroundColor: '#1a2433', scene: OfficeScene, scale: { mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH, }, }); } mountScene().catch((error) => { console.error('Failed to mount Office Phaser canvas', error); }); return () => { destroyed = true; game?.destroy(true); }; }, [office, selectedRunId]); return (
{showMetaBar ? (
WINTER OFFICE MAP 400 x 225 LOGIC / 800 x 450 RENDER {office.members.length} AGENTS {office.assignments.length} LINKS
) : null}
); }