Files
JARVIS/frontend/public/star-office/office-runtime.js

496 lines
17 KiB
JavaScript

(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)
})()