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>
8
frontend/public/star-office/NOTICE.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
Star Office stage integration notes
|
||||
|
||||
- Upstream project: https://github.com/ringhyacinth/Star-Office-UI
|
||||
- Upstream code license: MIT
|
||||
- Upstream README states art assets are non-commercial only
|
||||
|
||||
This local integration is intended as an internal/read-only stage inside Jarvis war-room.
|
||||
Review upstream asset terms before any external or commercial redistribution.
|
||||
BIN
frontend/public/star-office/assets/cats-spritesheet.webp
Normal file
|
After Width: | Height: | Size: 568 KiB |
BIN
frontend/public/star-office/assets/coffee-machine-shadow-v1.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
frontend/public/star-office/assets/coffee-machine-v3-grid.webp
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
frontend/public/star-office/assets/desk-v3.webp
Normal file
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 1.8 MiB |
BIN
frontend/public/star-office/assets/flowers-bloom-v2.webp
Normal file
|
After Width: | Height: | Size: 341 KiB |
BIN
frontend/public/star-office/assets/guest_anim_1.webp
Normal file
|
After Width: | Height: | Size: 468 B |
BIN
frontend/public/star-office/assets/guest_anim_2.webp
Normal file
|
After Width: | Height: | Size: 464 B |
BIN
frontend/public/star-office/assets/guest_anim_3.webp
Normal file
|
After Width: | Height: | Size: 306 B |
BIN
frontend/public/star-office/assets/guest_anim_4.webp
Normal file
|
After Width: | Height: | Size: 322 B |
BIN
frontend/public/star-office/assets/guest_anim_5.webp
Normal file
|
After Width: | Height: | Size: 364 B |
BIN
frontend/public/star-office/assets/guest_anim_6.webp
Normal file
|
After Width: | Height: | Size: 364 B |
BIN
frontend/public/star-office/assets/memo-bg.webp
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
frontend/public/star-office/assets/office_bg_small.webp
Normal file
|
After Width: | Height: | Size: 982 KiB |
BIN
frontend/public/star-office/assets/plants-spritesheet.webp
Normal file
|
After Width: | Height: | Size: 193 KiB |
BIN
frontend/public/star-office/assets/posters-spritesheet.webp
Normal file
|
After Width: | Height: | Size: 580 KiB |
BIN
frontend/public/star-office/assets/serverroom-spritesheet.webp
Normal file
|
After Width: | Height: | Size: 996 KiB |
BIN
frontend/public/star-office/assets/sofa-idle-v3.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
frontend/public/star-office/assets/sofa-shadow-v1.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
frontend/public/star-office/assets/star-idle-v5.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
BIN
frontend/public/star-office/assets/sync-animation-v3-grid.webp
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
369
frontend/public/star-office/index.html
Normal file
@@ -0,0 +1,369 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Jarvis Star Office Stage</title>
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: 'ArkPixel';
|
||||
src: url('./fonts/ark-pixel-12px-proportional-zh_cn.ttf.woff2') format('woff2');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'ArkPixelLatin';
|
||||
src: url('./fonts/ark-pixel-12px-proportional-latin.ttf.woff2') format('woff2');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #050810;
|
||||
--panel: rgba(8, 16, 29, 0.86);
|
||||
--panel-border: rgba(97, 211, 255, 0.18);
|
||||
--text: #e9fbff;
|
||||
--muted: #86cbe2;
|
||||
--accent: #6fe0ff;
|
||||
--warn: #ffd36b;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(64, 176, 255, 0.12), transparent 28%),
|
||||
radial-gradient(circle at top right, rgba(255, 196, 94, 0.08), transparent 20%),
|
||||
linear-gradient(180deg, #07111d 0%, #03070e 100%);
|
||||
color: var(--text);
|
||||
font-family: 'ArkPixel', 'ArkPixelLatin', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 14px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.hud {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--panel-border);
|
||||
background: var(--panel);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.hud-copy {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.hud-kicker,
|
||||
.meta-chip,
|
||||
.panel-kicker,
|
||||
.license-note {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.hud-title {
|
||||
font-size: 24px;
|
||||
color: var(--text);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.hud-subtitle {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: rgba(233, 251, 255, 0.74);
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.hud-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.meta-chip {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid rgba(111, 224, 255, 0.18);
|
||||
background: rgba(7, 18, 30, 0.92);
|
||||
color: var(--text);
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.stage {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.game-shell {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
border: 1px solid rgba(107, 214, 255, 0.16);
|
||||
background: rgba(6, 13, 22, 0.88);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#game-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#game-container canvas {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
image-rendering: pixelated;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.overlay-status {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
bottom: 14px;
|
||||
max-width: calc(100% - 28px);
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(111, 224, 255, 0.16);
|
||||
background: rgba(2, 8, 16, 0.82);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#coords-overlay,
|
||||
#coords-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.panels {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 330px;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border: 1px solid var(--panel-border);
|
||||
background: var(--panel);
|
||||
backdrop-filter: blur(12px);
|
||||
padding: 12px 14px;
|
||||
min-height: 168px;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 16px;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.memo-card {
|
||||
height: calc(100% - 30px);
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(111, 224, 255, 0.12);
|
||||
background:
|
||||
linear-gradient(rgba(255,255,255,0.03), rgba(255,255,255,0.03)),
|
||||
url('./assets/memo-bg.webp') center/cover no-repeat;
|
||||
color: #09131d;
|
||||
}
|
||||
|
||||
#memo-date {
|
||||
font-size: 11px;
|
||||
color: #2c4456;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
#memo-content {
|
||||
white-space: pre-wrap;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: #081019;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.legend-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(111, 224, 255, 0.1);
|
||||
background: rgba(7, 18, 30, 0.74);
|
||||
}
|
||||
|
||||
.legend-name {
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.legend-detail {
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: rgba(233, 251, 255, 0.72);
|
||||
}
|
||||
|
||||
.license-note {
|
||||
color: rgba(255, 211, 107, 0.86);
|
||||
}
|
||||
|
||||
#loading-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 9;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(3, 8, 14, 0.94);
|
||||
}
|
||||
|
||||
.loading-box {
|
||||
width: min(420px, calc(100% - 40px));
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 18px;
|
||||
border: 1px solid rgba(111, 224, 255, 0.16);
|
||||
background: rgba(7, 18, 30, 0.94);
|
||||
}
|
||||
|
||||
#loading-text {
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
#loading-progress-container {
|
||||
width: 100%;
|
||||
height: 12px;
|
||||
border: 1px solid rgba(111, 224, 255, 0.16);
|
||||
background: rgba(2, 8, 16, 0.82);
|
||||
}
|
||||
|
||||
#loading-progress-bar {
|
||||
width: 0%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #4ad4ff, #ffd36b);
|
||||
transition: width .2s ease;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.hud {
|
||||
grid-template-columns: 1fr;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.hud-meta {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.panels {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="hud">
|
||||
<div class="hud-copy">
|
||||
<div class="hud-kicker">Jarvis War Room / Star Office Stage</div>
|
||||
<div class="hud-title">STAR OFFICE</div>
|
||||
<div class="hud-subtitle">
|
||||
Pixel office stage adapted from the upstream Star Office UI project and wired into Jarvis office state endpoints.
|
||||
</div>
|
||||
</div>
|
||||
<div class="hud-meta">
|
||||
<div class="meta-chip">READ ONLY STAGE</div>
|
||||
<div class="meta-chip" id="live-state-chip">STATE / IDLE</div>
|
||||
<div class="meta-chip">API / <span id="agents-count">1</span> AGENTS</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="stage">
|
||||
<section class="game-shell">
|
||||
<div id="game-container"></div>
|
||||
<div id="loading-overlay">
|
||||
<div class="loading-box">
|
||||
<div id="loading-text">Loading Jarvis Star Office...</div>
|
||||
<div id="loading-progress-container">
|
||||
<div id="loading-progress-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overlay-status" id="status-text">[IDLE] Awaiting commands...</div>
|
||||
<div id="coords-overlay"></div>
|
||||
<button id="coords-toggle" type="button">coords</button>
|
||||
</section>
|
||||
|
||||
<section class="panels">
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<div class="panel-title">YESTERDAY MEMO</div>
|
||||
<div class="panel-kicker" id="memo-date">memo</div>
|
||||
</div>
|
||||
<div class="memo-card">
|
||||
<div class="panel-kicker">OPERATIONS NOTEBOOK</div>
|
||||
<div id="memo-content">Loading memo...</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<div class="panel-title">INTEGRATION NOTE</div>
|
||||
<div class="panel-kicker">WAR ROOM</div>
|
||||
</div>
|
||||
<div class="legend-list">
|
||||
<div class="legend-item">
|
||||
<div class="legend-name">State Mapping</div>
|
||||
<div class="legend-detail">Idle stays in lounge. Writing, researching and executing move to the desk. Syncing lights the server room. Error wakes the bug bay.</div>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-name">Agent Layer</div>
|
||||
<div class="legend-detail">The main agent uses Jarvis office state. Visitor sprites fall back to local demo agents when no extra office agents are available.</div>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-name">License Note</div>
|
||||
<div class="legend-detail license-note">Upstream code is MIT. Upstream art assets are marked non-commercial by the project README. Keep that constraint in mind before external distribution.</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="./vendor/phaser-3.80.1.min.js"></script>
|
||||
<script src="./office-runtime.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
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)
|
||||
})()
|
||||