feat(docs): add development documentation, prototypes, and war-room components
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
495
frontend/public/star-office/office-runtime.js
Normal file
495
frontend/public/star-office/office-runtime.js
Normal file
@@ -0,0 +1,495 @@
|
||||
(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)
|
||||
})()
|
||||
Reference in New Issue
Block a user