(function () { const ASSET_BASE = './assets' const API_BASE = '/api/office' const GAME_WIDTH = 1280 const GAME_HEIGHT = 720 const POLL_MS = 2500 const STATUS_META = { idle: { label: 'IDLE', detail: 'Awaiting commands', area: 'breakroom', bubble: ['Standing by.', 'Quiet office. Ready when you are.'] }, writing: { label: 'WRITING', detail: 'Working at the command desk', area: 'writing', bubble: ['Drafting the next move.', 'Keeping the mainline clean.'] }, researching: { label: 'RESEARCH', detail: 'Collecting evidence and context', area: 'writing', bubble: ['Tracing the signal.', 'Checking the facts before acting.'] }, executing: { label: 'EXECUTING', detail: 'Pushing tasks through the pipeline', area: 'writing', bubble: ['Executing the plan.', 'Reducing the task to concrete steps.'] }, syncing: { label: 'SYNCING', detail: 'Aligning state in the server room', area: 'syncing', bubble: ['Sync in progress.', 'Writing the latest changes to the shared state.'] }, error: { label: 'ERROR', detail: 'Investigating a failure path', area: 'error', bubble: ['Something broke. Looking at it now.', 'The bug bay is active.'] }, } const AREA_POSITIONS = { breakroom: { x: 798, y: 272 }, writing: { x: 217, y: 343 }, syncing: { x: 1157, y: 592 }, error: { x: 1007, y: 221 }, } const VISITOR_SLOTS = { breakroom: [ { x: 700, y: 350 }, { x: 855, y: 322 }, { x: 926, y: 362 }, ], writing: [ { x: 386, y: 356 }, { x: 462, y: 319 }, { x: 538, y: 356 }, ], syncing: [ { x: 1035, y: 520 }, { x: 1096, y: 560 }, { x: 974, y: 560 }, ], error: [ { x: 1080, y: 262 }, { x: 1138, y: 250 }, { x: 1078, y: 324 }, ], } const DEMO_VISITORS = [ { agentId: 'demo-nika', name: 'Nika', state: 'researching' }, { agentId: 'demo-mercury', name: 'Mercury', state: 'writing' }, { agentId: 'demo-echo', name: 'Echo', state: 'syncing' }, ] let game let mainState = 'idle' let mainDetail = STATUS_META.idle.detail let lastPollAt = 0 let lastBubbleAt = 0 let lastVisitorShuffleAt = 0 let lastCatBubbleAt = 0 let lastAgentsCount = 1 let lastRemoteAgents = [] let officeBgSprite let sofa let sofaShadow let starIdle let starWorking let serverRoom let syncAnim let errorBug let mainBubble = null let catBubble = null let guestSprites = {} const statusText = document.getElementById('status-text') const stateChip = document.getElementById('live-state-chip') const agentsCount = document.getElementById('agents-count') const memoDate = document.getElementById('memo-date') const memoContent = document.getElementById('memo-content') const loadingText = document.getElementById('loading-text') const loadingBar = document.getElementById('loading-progress-bar') const loadingOverlay = document.getElementById('loading-overlay') function setLoading(progress, text) { if (loadingBar) loadingBar.style.width = `${progress}%` if (loadingText) loadingText.textContent = text } function hideLoading() { if (!loadingOverlay) return loadingOverlay.style.transition = 'opacity .35s ease' loadingOverlay.style.opacity = '0' window.setTimeout(() => { loadingOverlay.style.display = 'none' }, 350) } async function fetchJson(path) { const response = await fetch(path, { cache: 'no-store' }) if (!response.ok) { throw new Error(`${response.status} ${response.statusText}`) } return response.json() } async function loadMemo() { try { const payload = await fetchJson(`${API_BASE}/yesterday-memo?t=${Date.now()}`) memoDate.textContent = payload.date || 'memo' memoContent.textContent = payload.memo || 'No memo available.' } catch (error) { memoDate.textContent = 'memo unavailable' memoContent.textContent = 'Unable to load the office memo.' console.error('Failed to load office memo', error) } } function normalizeState(state) { const raw = String(state || '').trim().toLowerCase() if (!raw) return 'idle' if (raw === 'working' || raw === 'run' || raw === 'running') return 'executing' if (raw === 'sync') return 'syncing' if (raw === 'research') return 'researching' return STATUS_META[raw] ? raw : 'idle' } function updateHud() { const meta = STATUS_META[mainState] || STATUS_META.idle statusText.textContent = `[${meta.label}] ${mainDetail || meta.detail}` stateChip.textContent = `STATE / ${meta.label}` agentsCount.textContent = String(lastAgentsCount) } function clearBubble(target) { if (target) { target.destroy() } } function spawnBubble(anchorX, anchorY, text, tint = 0xffffff) { const bg = game.add.rectangle(anchorX, anchorY, Math.max(110, text.length * 8 + 24), 28, 0xffffff, 0.96) bg.setStrokeStyle(2, tint) const label = game.add.text(anchorX, anchorY, text, { fontFamily: 'ArkPixel, ArkPixelLatin, monospace', fontSize: '11px', color: '#031019', align: 'center', }).setOrigin(0.5) const container = game.add.container(0, 0, [bg, label]) container.setDepth(2400) return container } function maybeShowMainBubble(time) { if (!game || mainState === 'idle') return if (time - lastBubbleAt < 7000) return lastBubbleAt = time clearBubble(mainBubble) const meta = STATUS_META[mainState] || STATUS_META.idle const text = meta.bubble[Math.floor(Math.random() * meta.bubble.length)] let anchor = AREA_POSITIONS.breakroom if (mainState === 'syncing') anchor = AREA_POSITIONS.syncing else if (mainState === 'error') anchor = AREA_POSITIONS.error else if (mainState !== 'idle') anchor = AREA_POSITIONS.writing mainBubble = spawnBubble(anchor.x, anchor.y - 88, text, 0x61d3ff) window.setTimeout(() => { clearBubble(mainBubble) mainBubble = null }, 3200) } function maybeShowCatBubble(time) { if (!game || !window.catSprite) return if (time - lastCatBubbleAt < 17000) return lastCatBubbleAt = time clearBubble(catBubble) catBubble = spawnBubble(window.catSprite.x, window.catSprite.y - 64, 'mrrp', 0xd4a574) window.setTimeout(() => { clearBubble(catBubble) catBubble = null }, 2200) } function applyMainState(nextState, detail) { mainState = normalizeState(nextState) mainDetail = detail || STATUS_META[mainState].detail updateHud() sofa.setTexture('sofa_idle') if (mainState === 'idle') { starIdle.setVisible(true) if (!starIdle.anims.isPlaying) starIdle.anims.play('star_idle', true) starWorking.setVisible(false) starWorking.anims.stop() syncAnim.setVisible(false) syncAnim.anims.stop() syncAnim.setFrame(0) errorBug.setVisible(false) errorBug.anims.stop() serverRoom.anims.stop() serverRoom.setFrame(0) return } starIdle.setVisible(false) starIdle.anims.stop() if (mainState === 'syncing') { starWorking.setVisible(false) starWorking.anims.stop() errorBug.setVisible(false) errorBug.anims.stop() syncAnim.setVisible(true) syncAnim.anims.play('sync_anim', true) serverRoom.anims.play('serverroom_on', true) return } syncAnim.setVisible(false) syncAnim.anims.stop() syncAnim.setFrame(0) if (mainState === 'error') { starWorking.setVisible(false) starWorking.anims.stop() errorBug.setVisible(true) errorBug.anims.play('error_bug', true) serverRoom.anims.play('serverroom_on', true) return } errorBug.setVisible(false) errorBug.anims.stop() starWorking.setVisible(true) starWorking.anims.play('star_working', true) serverRoom.anims.play('serverroom_on', true) } function randomizeDemoVisitors() { const states = Object.keys(STATUS_META) DEMO_VISITORS.forEach((agent, index) => { agent.state = states[(index + Math.floor(Math.random() * states.length)) % states.length] }) } function destroyGuestSprites() { Object.values(guestSprites).forEach((entry) => entry.destroy()) guestSprites = {} } function renderVisitors(apiAgents = []) { const visitors = apiAgents.filter((agent) => !agent.isMain) const effectiveVisitors = visitors.length > 0 ? visitors : DEMO_VISITORS destroyGuestSprites() lastAgentsCount = Math.max(1, apiAgents.length) updateHud() effectiveVisitors.forEach((agent, index) => { const state = normalizeState(agent.state) const area = STATUS_META[state]?.area || 'breakroom' const slot = VISITOR_SLOTS[area][index % VISITOR_SLOTS[area].length] const sheetKey = `guest_anim_${(index % 6) + 1}` const sprite = game.add.sprite(slot.x, slot.y, sheetKey, 0).setOrigin(0.5) const animKey = `${sheetKey}_idle` if (game.anims.exists(animKey)) { sprite.anims.play(animKey, true) } sprite.setDepth(1200) const label = game.add.text(slot.x, slot.y - 34, agent.name, { fontFamily: 'ArkPixel, ArkPixelLatin, monospace', fontSize: '11px', color: '#ecfdf5', stroke: '#0f172a', strokeThickness: 4, }).setOrigin(0.5) label.setDepth(1201) guestSprites[agent.agentId || `demo-${index}`] = game.add.container(0, 0, [sprite, label]) }) } async function pollOfficeState(force = false) { const now = Date.now() if (!force && now - lastPollAt < POLL_MS) return lastPollAt = now try { const [statusPayload, agentsPayload] = await Promise.all([ fetchJson(`${API_BASE}/status?t=${now}`), fetchJson(`${API_BASE}/agents?t=${now}`), ]) applyMainState(statusPayload.state, statusPayload.detail) lastRemoteAgents = Array.isArray(agentsPayload) ? agentsPayload : [] renderVisitors(lastRemoteAgents) } catch (error) { applyMainState('error', 'Office API unavailable') console.error('Failed to poll office state', error) } } function preload() { setLoading(2, 'Loading Star Office room...') const fileCount = 17 let loadedCount = 0 this.load.on('filecomplete', () => { loadedCount += 1 setLoading(Math.min(95, Math.round((loadedCount / fileCount) * 100)), 'Streaming pixel office assets...') }) this.load.on('complete', () => { setLoading(100, 'Star Office ready.') hideLoading() }) this.load.image('office_bg', `${ASSET_BASE}/office_bg_small.webp`) this.load.spritesheet('star_idle', `${ASSET_BASE}/star-idle-v5.png`, { frameWidth: 256, frameHeight: 256 }) this.load.image('sofa_idle', `${ASSET_BASE}/sofa-idle-v3.png`) this.load.image('sofa_shadow', `${ASSET_BASE}/sofa-shadow-v1.png`) this.load.spritesheet('plants', `${ASSET_BASE}/plants-spritesheet.webp`, { frameWidth: 160, frameHeight: 160 }) this.load.spritesheet('posters', `${ASSET_BASE}/posters-spritesheet.webp`, { frameWidth: 160, frameHeight: 160 }) this.load.spritesheet('coffee_machine', `${ASSET_BASE}/coffee-machine-v3-grid.webp`, { frameWidth: 230, frameHeight: 230 }) this.load.image('coffee_machine_shadow', `${ASSET_BASE}/coffee-machine-shadow-v1.png`) this.load.spritesheet('serverroom', `${ASSET_BASE}/serverroom-spritesheet.webp`, { frameWidth: 180, frameHeight: 251 }) this.load.spritesheet('error_bug', `${ASSET_BASE}/error-bug-spritesheet-grid.webp`, { frameWidth: 220, frameHeight: 220 }) this.load.spritesheet('cats', `${ASSET_BASE}/cats-spritesheet.webp`, { frameWidth: 160, frameHeight: 160 }) this.load.spritesheet('star_working', `${ASSET_BASE}/star-working-spritesheet-grid.webp`, { frameWidth: 300, frameHeight: 300 }) this.load.spritesheet('sync_anim', `${ASSET_BASE}/sync-animation-v3-grid.webp`, { frameWidth: 256, frameHeight: 256 }) this.load.image('desk_v2', `${ASSET_BASE}/desk-v3.webp`) this.load.spritesheet('flowers', `${ASSET_BASE}/flowers-bloom-v2.webp`, { frameWidth: 128, frameHeight: 128 }) for (let index = 1; index <= 6; index += 1) { this.load.spritesheet(`guest_anim_${index}`, `${ASSET_BASE}/guest_anim_${index}.webp`, { frameWidth: 32, frameHeight: 32 }) } } function create() { game = this officeBgSprite = this.add.image(640, 360, 'office_bg') officeBgSprite.setDisplaySize(GAME_WIDTH, GAME_HEIGHT) sofaShadow = this.add.image(798, 272, 'sofa_shadow').setOrigin(0.5) sofaShadow.setDepth(9) sofa = this.add.sprite(798, 272, 'sofa_idle').setOrigin(0.5) sofa.setDepth(10) this.anims.create({ key: 'star_idle', frames: this.anims.generateFrameNumbers('star_idle', { start: 0, end: Math.max(0, this.textures.get('star_idle').frameTotal - 1) }), frameRate: 12, repeat: -1, }) this.anims.create({ key: 'coffee_machine', frames: this.anims.generateFrameNumbers('coffee_machine', { start: 0, end: Math.max(0, this.textures.get('coffee_machine').frameTotal - 2) }), frameRate: 12, repeat: -1, }) this.anims.create({ key: 'serverroom_on', frames: this.anims.generateFrameNumbers('serverroom', { start: 0, end: Math.max(0, this.textures.get('serverroom').frameTotal - 2) }), frameRate: 6, repeat: -1, }) this.anims.create({ key: 'star_working', frames: this.anims.generateFrameNumbers('star_working', { start: 0, end: 37 }), frameRate: 12, repeat: -1, }) this.anims.create({ key: 'error_bug', frames: this.anims.generateFrameNumbers('error_bug', { start: 0, end: 71 }), frameRate: 12, repeat: -1, }) this.anims.create({ key: 'sync_anim', frames: this.anims.generateFrameNumbers('sync_anim', { start: 1, end: 52 }), frameRate: 12, repeat: -1, }) for (let index = 1; index <= 6; index += 1) { this.anims.create({ key: `guest_anim_${index}_idle`, frames: this.anims.generateFrameNumbers(`guest_anim_${index}`, { start: 0, end: 7 }), frameRate: 8, repeat: -1, }) } starIdle = game.physics.add.sprite(798, 272, 'star_idle') starIdle.setOrigin(0.5) starIdle.setScale(0.58) starIdle.setDepth(20) starIdle.anims.play('star_idle', true) const plantFrames = 16 ;[ [565, 178], [230, 185], [977, 496], ].forEach(([x, y]) => { const plant = game.add.sprite(x, y, 'plants', Math.floor(Math.random() * plantFrames)).setOrigin(0.5) plant.setDepth(5) }) const poster = game.add.sprite(252, 66, 'posters', Math.floor(Math.random() * Math.max(1, this.textures.get('posters').frameTotal - 1))).setOrigin(0.5) poster.setDepth(4) const cat = game.add.sprite(94, 557, 'cats', Math.floor(Math.random() * Math.max(1, this.textures.get('cats').frameTotal - 1))).setOrigin(0.5) cat.setDepth(2000) window.catSprite = cat const coffeeShadow = this.add.image(659, 397, 'coffee_machine_shadow').setOrigin(0.5) coffeeShadow.setDepth(98) const coffeeMachine = this.add.sprite(659, 397, 'coffee_machine').setOrigin(0.5) coffeeMachine.setDepth(99) coffeeMachine.anims.play('coffee_machine', true) serverRoom = this.add.sprite(1021, 142, 'serverroom', 0).setOrigin(0.5) serverRoom.setDepth(2) serverRoom.setFrame(0) const desk = this.add.image(218, 417, 'desk_v2').setOrigin(0.5) desk.setDepth(1001) const flower = this.add.sprite(310, 390, 'flowers', Math.floor(Math.random() * 16)).setOrigin(0.5) flower.setScale(0.8) flower.setDepth(1100) errorBug = this.add.sprite(1007, 221, 'error_bug', 0).setOrigin(0.5) errorBug.setDepth(50) errorBug.setScale(0.9) errorBug.setVisible(false) starWorking = this.add.sprite(217, 343, 'star_working', 0).setOrigin(0.5) starWorking.setVisible(false) starWorking.setScale(0.9) starWorking.setDepth(900) syncAnim = this.add.sprite(1157, 592, 'sync_anim', 0).setOrigin(0.5) syncAnim.setDepth(40) syncAnim.setVisible(false) applyMainState('idle', STATUS_META.idle.detail) randomizeDemoVisitors() renderVisitors([]) pollOfficeState(true) loadMemo() } function update(time) { if (time - lastPollAt > POLL_MS) { pollOfficeState() } if (time - lastVisitorShuffleAt > 8000) { lastVisitorShuffleAt = time randomizeDemoVisitors() if (lastRemoteAgents.filter((agent) => !agent.isMain).length === 0) { renderVisitors(lastRemoteAgents) } } maybeShowMainBubble(time) maybeShowCatBubble(time) } const config = { type: Phaser.AUTO, width: GAME_WIDTH, height: GAME_HEIGHT, parent: 'game-container', pixelArt: true, physics: { default: 'arcade', arcade: { gravity: { y: 0 }, debug: false }, }, scene: { preload, create, update }, } setLoading(0, 'Booting Star Office runtime...') new Phaser.Game(config) })()