Files
Star-Office-UI/frontend/index.html
T

2122 lines
93 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Star 的像素办公室</title>
<style>
@font-face {
font-family: 'ArkPixel';
src: url('/static/fonts/ark-pixel-12px-proportional-zh_cn.ttf.woff2') format('woff2');
font-weight: normal;
font-style: normal;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #1a1a2e;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
min-height: 100vh;
font-family: 'ArkPixel', 'Courier New', monospace;
padding: 20px 0;
gap: 20px;
overflow-x: hidden;
}
/* 底部面板容器 */
#bottom-panels {
display: flex;
gap: 20px;
width: 1280px;
max-width: 95vw;
}
#game-container {
border: 4px solid #e94560;
image-rendering: pixelated;
width: 1280px;
height: 720px;
max-width: 95vw;
/* 手机上用“可见高度”限制,避免被底部面板挤压导致裁切 */
max-height: 70vh;
/* 关键:强制容器保持 16:9,避免 canvas 在不同布局下被拉伸变形 */
aspect-ratio: 16 / 9;
}
#game-container canvas {
width: 100% !important;
height: 100% !important;
image-rendering: pixelated;
/* 再兜底一次:即使外层高度变化,也不要拉伸变形 */
object-fit: contain;
}
#loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #1a1a2e;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 100000;
}
#loading-text {
color: #ffd700;
font-size: 18px;
margin-bottom: 20px;
}
#loading-progress-container {
width: 300px;
height: 20px;
background: #333;
border: 2px solid #555;
border-radius: 4px;
}
#loading-progress-bar {
height: 100%;
background: linear-gradient(90deg, #e94560, #ffd700);
width: 0%;
transition: width 0.3s ease;
}
#status-text {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
color: #eee;
font-size: 14px;
background: rgba(0,0,0,0.7);
padding: 10px 20px;
border-radius: 4px;
max-width: 90%;
text-align: center;
font-family: 'ArkPixel', 'Courier New', monospace;
}
/* 状态控制栏 */
#control-bar {
background: #2c2f3a;
padding: 10px 10px 12px;
border-radius: 8px;
border: 2px solid #e94560;
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
width: 380px;
height: 300px;
display: flex;
flex-direction: column;
gap: 10px;
}
#control-bar-title {
color: #ffd700;
font-size: 16px;
font-weight: bold;
text-align: center;
letter-spacing: 1px;
padding: 6px 0 10px;
border-bottom: 2px solid #4a4f5f;
}
#control-buttons {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
align-content: start;
padding-top: 4px;
}
#control-buttons button { height: 52px; }
#control-bar button {
background: #3a3f4f;
color: #fff;
border: 2px solid #555;
border-radius: 4px;
padding: 8px 10px;
cursor: pointer;
font-family: 'ArkPixel', monospace;
font-size: 12px;
transition: all 0.2s;
}
#control-bar button:hover {
background: #4a4f5f;
border-color: #e94560;
}
/* Guest Agent 名单面板(右下角) */
#guest-agent-panel {
width: 360px;
height: 300px;
background: #2c2f3a;
padding: 12px;
border-radius: 8px;
border: 2px solid #e94560;
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
display: flex;
flex-direction: column;
gap: 10px;
}
#guest-agent-panel-title {
color: #ffd700;
font-size: 16px;
font-weight: bold;
text-align: center;
letter-spacing: 1px;
padding-bottom: 8px;
border-bottom: 2px solid #4a4f5f;
margin-bottom: 6px;
}
#guest-agent-list {
flex-grow: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
padding-right: 4px;
}
#guest-agent-list::-webkit-scrollbar { width: 6px; }
#guest-agent-list::-webkit-scrollbar-track { background: #1a1a2e; }
#guest-agent-list::-webkit-scrollbar-thumb { background: #555; border-radius: 3px; }
.guest-agent-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
background: #3a3f4f;
padding: 8px 10px;
border-radius: 6px;
border: 1px solid #555;
}
.guest-agent-name {
color: #fff;
font-size: 14px;
flex-shrink: 0;
}
.guest-agent-buttons {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.guest-agent-buttons button {
padding: 6px 10px;
border-radius: 4px;
border: 2px solid #555;
background: #4a4f5f;
color: #fff;
font-family: 'ArkPixel', monospace;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
}
.guest-agent-buttons button:hover {
background: #5a5f6f;
border-color: #e94560;
}
.guest-agent-buttons button.leave-btn {
background: #5a1818;
border-color: #e94560;
}
.guest-agent-buttons button.leave-btn:hover {
background: #6a2828;
}
/* Memo 区域 - 4:3 小正方形 */
#memo-panel {
width: 400px;
height: 300px;
background-image: url('/static/memo-bg.png');
background-size: cover;
background-position: center;
border: 3px solid #e94560;
border-radius: 8px;
padding: 14px 16px;
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
display: flex;
flex-direction: column;
overflow: hidden;
}
#memo-title {
color: #ffd700;
font-size: 14px;
margin-bottom: 6px;
text-align: center;
letter-spacing: 1px;
flex-shrink: 0;
}
#memo-date {
color: #888;
font-size: 10px;
margin-bottom: 8px;
text-align: right;
flex-shrink: 0;
position: relative;
left: -30px; /* move date left by 30px */
}
#memo-content {
color: #3b3b32;
font-size: 12px;
line-height: 1.8;
white-space: pre-wrap;
word-wrap: break-word;
overflow-y: auto;
flex-grow: 1;
padding-right: 4px;
position: relative;
left: 80px; /* move content right by 80px */
}
#memo-content::-webkit-scrollbar {
width: 6px;
}
#memo-content::-webkit-scrollbar-track {
background: #1a1a2e;
}
#memo-content::-webkit-scrollbar-thumb {
background: #444;
border-radius: 3px;
}
#memo-placeholder {
color: #666;
font-style: italic;
text-align: center;
padding: 20px 0;
}
.memo-decoration {
text-align: center;
margin: 4px 0;
color: #555;
font-size: 10px;
flex-shrink: 0;
}
/* 手机端专属适配(不影响桌面) */
@media (max-width: 900px), (pointer: coarse) {
html, body {
height: 100%;
}
body {
padding: 0;
gap: 0;
overflow-x: hidden;
overflow-y: auto;
align-items: stretch;
}
#game-container {
width: 100vw;
height: 66.666vh; /* 办公室占 2/3 屏幕高度 */
max-width: 100vw;
max-height: 66.666vh;
border-width: 0;
border-radius: 0;
aspect-ratio: auto;
flex: 0 0 auto;
touch-action: pan-y; /* 保留页面纵向滚动手势 */
overflow: hidden;
}
#bottom-panels {
width: 100vw;
max-width: 100vw;
min-height: 33.334vh; /* 余下约 1/3 可见区 */
padding: 10px 10px 16px;
display: flex;
flex-direction: column;
gap: 10px;
flex: 0 0 auto;
}
#memo-panel,
#control-bar,
#guest-agent-panel {
width: 100%;
height: auto;
min-height: 180px;
}
#memo-panel { min-height: 220px; }
#control-bar { min-height: 210px; }
#guest-agent-panel { min-height: 220px; }
#memo-date {
left: 0;
text-align: left;
margin-bottom: 6px;
}
#memo-content {
left: 0;
font-size: 13px;
line-height: 1.7;
}
#control-bar-title,
#guest-agent-panel-title,
#memo-title {
font-size: 14px;
}
#control-buttons button,
#control-bar button,
.guest-agent-buttons button {
font-size: 12px;
min-height: 44px;
}
.guest-agent-item {
align-items: flex-start;
gap: 10px;
flex-direction: column;
}
.guest-agent-buttons {
width: 100%;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
#status-text {
bottom: 8px;
font-size: 12px;
padding: 8px 12px;
}
#coords-toggle,
#pan-toggle {
font-size: 12px !important;
padding: 6px 8px !important;
}
}
</style>
</head>
<body>
<!-- 加载遮罩 -->
<div id="loading-overlay">
<div id="loading-text">正在加载 Star 的像素办公室...</div>
<div id="loading-progress-container">
<div id="loading-progress-bar"></div>
</div>
</div>
<div id="game-container"></div>
<!-- 底部面板容器 -->
<div id="bottom-panels">
<!-- Memo 面板 -->
<div id="memo-panel">
<div id="memo-title">— 昨 日 小 记 —</div>
<div id="memo-date"></div>
<div class="memo-decoration">─ ─ ─ ─ ─</div>
<div id="memo-content">
<div id="memo-placeholder">加载中...</div>
</div>
<div class="memo-decoration">─ ─ ─ ─ ─</div>
</div>
<!-- 状态控制栏 -->
<div id="control-bar">
<div id="control-bar-title">Star 状态</div>
<div id="control-buttons">
<button onclick="setState('idle','待命')">待命</button>
<button onclick="setState('writing','工作中')">工作</button>
<button onclick="setState('syncing','同步中')">同步</button>
<button onclick="setState('error','报警中')">报警</button>
</div>
</div>
<!-- Guest Agent 名单面板(右下角) -->
<div id="guest-agent-panel">
<div id="guest-agent-panel-title">— 访 客 列 表 —</div>
<div id="guest-agent-list">
<div style="color:#9ca3af;font-size:12px;text-align:center;padding:20px 0;">正在加载访客...</div>
</div>
</div>
</div>
<div id="status-text">加载中...</div>
<div id="coords-overlay" style="display:none; position:fixed; pointer-events:none; background:rgba(0,0,0,0.85); color:#fff; font-family:ArkPixel,monospace; font-size:14px; padding:8px 12px; border-radius:4px; z-index:99999;">
<div id="coords-display">X: 0 | Y: 0</div>
</div>
<button id="coords-toggle" style="position:fixed; top:calc(env(safe-area-inset-top, 0px) + 12px); right:12px; z-index:999999; padding:8px 10px; font-family:ArkPixel,monospace; font-size:13px; cursor:pointer; border:2px solid #333; border-radius:5px; background:#333; color:#fff;">
显示坐标
</button>
<button id="pan-toggle" style="position:fixed; top:calc(env(safe-area-inset-top, 0px) + 12px); left:12px; z-index:999999; padding:8px 10px; font-family:ArkPixel,monospace; font-size:13px; cursor:pointer; border:2px solid #333; border-radius:5px; background:#333; color:#fff;">
移动视野
</button>
<script src="https://cdn.jsdelivr.net/npm/phaser@3.80.1/dist/phaser.min.js"></script>
<script>
// 检测浏览器是否支持 WebP
let supportsWebP = false;
// 方法 1: 使用 canvas 检测
function checkWebPSupport() {
return new Promise((resolve) => {
const canvas = document.createElement('canvas');
if (canvas.getContext && canvas.getContext('2d')) {
resolve(canvas.toDataURL('image/webp').indexOf('data:image/webp') === 0);
} else {
resolve(false);
}
});
}
// 方法 2: 使用 image 检测(备用)
function checkWebPSupportFallback() {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => resolve(true);
img.onerror = () => resolve(false);
img.src = 'data:image/webp;base64,UklGRkoAAABXRUJQVlA4WAoAAAAQAAAAAAAAAAAAQUxQSAwAAAABBxAR/Q9ERP8DAABWUDggGAAAADABAJ0BKgEAAQADADQlpAADcAD++/1QAA==';
});
}
// 获取文件扩展名(根据 WebP 支持情况)
function getExt(pngFile) {
// star-working-spritesheet.png 太宽了,WebP 不支持,始终用 PNG
if (pngFile === 'star-working-spritesheet.png') {
return '.png';
}
return supportsWebP ? '.webp' : '.png';
}
const IS_TOUCH_DEVICE = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0) || window.matchMedia('(pointer: coarse)').matches;
const config = {
type: Phaser.AUTO,
width: 1280,
height: 720,
parent: 'game-container',
pixelArt: true,
// 桌面端保持 FIT;手机端改 RESIZE,让办公室区域真正吃满分配到的高度。
scale: {
mode: IS_TOUCH_DEVICE ? Phaser.Scale.RESIZE : Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH,
width: 1280,
height: 720
},
physics: { default: 'arcade', arcade: { gravity: { y: 0 }, debug: false } },
scene: { preload: preload, create: create, update: update }
};
let totalAssets = 0;
let loadedAssets = 0;
let loadingProgressBar, loadingProgressContainer, loadingOverlay, loadingText;
// Memo 相关函数
async function loadMemo() {
const memoDate = document.getElementById('memo-date');
const memoContent = document.getElementById('memo-content');
try {
const response = await fetch('/yesterday-memo?t=' + Date.now(), { cache: 'no-store' });
const data = await response.json();
if (data.success && data.memo) {
memoDate.textContent = data.date || '';
memoContent.innerHTML = data.memo.replace(/\n/g, '<br>');
} else {
memoContent.innerHTML = '<div id="memo-placeholder">暂无昨日日记</div>';
}
} catch (e) {
console.error('加载 memo 失败:', e);
memoContent.innerHTML = '<div id="memo-placeholder">加载失败</div>';
}
}
// 更新加载进度
function updateLoadingProgress() {
loadedAssets++;
const percent = Math.min(100, Math.round((loadedAssets / totalAssets) * 100));
if (loadingProgressBar) {
loadingProgressBar.style.width = percent + '%';
}
if (loadingText) {
loadingText.textContent = `正在加载 Star 的像素办公室... ${percent}%`;
}
}
// 隐藏加载界面
function hideLoadingOverlay() {
setTimeout(() => {
if (loadingOverlay) {
loadingOverlay.style.transition = 'opacity 0.5s ease';
loadingOverlay.style.opacity = '0';
setTimeout(() => {
loadingOverlay.style.display = 'none';
}, 500);
}
}, 300);
}
// 兜底:某些移动网络/CDN 抖动时,避免一直卡在“加载中”遮罩
setTimeout(() => {
if (loadingOverlay && loadingOverlay.style.display !== 'none') {
hideLoadingOverlay();
}
}, 8000);
// 懒加载逻辑已取消(体验优先:装饰首屏直接出现)
const STATES = {
idle: { name: '待命', area: 'breakroom' },
writing: { name: '整理文档', area: 'writing' },
researching: { name: '搜索信息', area: 'researching' },
executing: { name: '执行任务', area: 'writing' },
syncing: { name: '同步备份', area: 'writing' },
error: { name: '出错了', area: 'error' }
};
const BUBBLE_TEXTS = {
idle: [
'待命中:耳朵竖起来了',
'我在这儿,随时可以开工',
'先把桌面收拾干净再说',
'呼——给大脑放个风',
'今天也要优雅地高效',
'等待,是为了更准确的一击',
'咖啡还热,灵感也还在',
'我在后台给你加 Buff',
'状态:静心 / 充电',
'小猫说:慢一点也没关系'
],
// writing / researching / executing 都视为“工作”
writing: [
'进入专注模式:勿扰',
'先把关键路径跑通',
'我来把复杂变简单',
'把 bug 关进笼子里',
'写到一半,先保存',
'把每一步都做成可回滚',
'今天的进度,明天的底气',
'先收敛,再发散',
'让系统变得更可解释',
'稳住,我们能赢'
],
researching: [
'我在挖证据链',
'让我把信息熬成结论',
'找到了:关键在这里',
'先把变量控制住',
'我在查:它为什么会这样',
'把直觉写成验证',
'先定位,再优化',
'别急,先画因果图'
],
executing: [
'执行中:不要眨眼',
'把任务切成小块逐个击破',
'开始跑 pipeline',
'一键推进:走你',
'让结果自己说话',
'先做最小可行,再做最美版本'
],
syncing: [
'同步中:把今天锁进云里',
'备份不是仪式,是安全感',
'写入中…别断电',
'把变更交给时间戳',
'云端对齐:咔哒',
'同步完成前先别乱动',
'把未来的自己从灾难里救出来',
'多一份备份,少一份后悔'
],
error: [
'警报响了:先别慌',
'我闻到 bug 的味道了',
'先复现,再谈修复',
'把日志给我,我会说人话',
'错误不是敌人,是线索',
'把影响面圈起来',
'先止血,再手术',
'我在:马上定位根因',
'别怕,这种我见多了',
'报警中:让问题自己现形'
],
cat: [
'喵~',
'咕噜咕噜…',
'尾巴摇一摇',
'晒太阳最开心',
'有人来看我啦',
'我是这个办公室的吉祥物',
'伸个懒腰',
'今天的罐罐准备好了吗',
'呼噜呼噜',
'这个位置视野最好'
]
};
let game, star, sofa, serverroom, areas = {}, currentState = 'idle', pendingDesiredState = null, statusText, lastFetch = 0, lastBlink = 0, lastBubble = 0, targetX = 660, targetY = 170, bubble = null, typewriterText = '', typewriterTarget = '', typewriterIndex = 0, lastTypewriter = 0, syncAnimSprite = null, catBubble = null;
let isMoving = false;
let waypoints = []; // list of (x,y) to walk through in order
let lastWanderAt = 0;
let coordsOverlay, coordsDisplay, coordsToggle;
let showCoords = false;
let guestAgents = [];
let guestSprites = {}; // agentId -> {sprite, nameText}
let guestBubbles = {}; // agentId -> bubble container
const GUEST_AVATARS = ['guest_role_1','guest_role_2','guest_role_3','guest_role_4','guest_role_5','guest_role_6'];
let guestTweens = {}; // agentId -> {move, name}
let hiddenDemoNames = new Set();
const DEMO_MODE = new URLSearchParams(window.location.search).get('demo') === '1';
const FETCH_INTERVAL = 2000;
const GUEST_AGENTS_FETCH_INTERVAL = 3500;
const BLINK_INTERVAL = 2500;
const BUBBLE_INTERVAL = 8000;
const CAT_BUBBLE_INTERVAL = 18000; // cat bubble much less frequent
let lastCatBubble = 0;
let lastGuestAgentsFetch = 0;
let lastGuestBubbleAt = 0;
const TYPEWRITER_DELAY = 50;
let lastSeenGuestIds = new Set(); // 用于检测新加入的访客,触发欢迎气泡
let guestWelcomeInitialized = false;
// 状态控制栏函数(用于测试)
function setState(state, detail) {
fetch('/set_state', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ state, detail })
}).then(() => fetchStatus());
}
// Guest Agent 离开房间
function removeGuestSpriteByName(name) {
const target = guestAgents.find(a => (a.name || '') === name);
if (target && guestSprites[target.agentId]) {
guestSprites[target.agentId].sprite.destroy();
guestSprites[target.agentId].nameText.destroy();
delete guestSprites[target.agentId];
}
if (target && guestBubbles[target.agentId]) {
guestBubbles[target.agentId].destroy();
delete guestBubbles[target.agentId];
}
}
function leaveGuestAgent(agentId, name) {
fetch('/leave-agent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ agentId, name })
}).then(response => response.json()).then(data => {
if (data.ok) {
// 优先按 agentId 清理,避免重名误伤
if (agentId && guestSprites[agentId]) {
guestSprites[agentId].sprite.destroy();
guestSprites[agentId].nameText.destroy();
delete guestSprites[agentId];
}
if (agentId && guestBubbles[agentId]) {
guestBubbles[agentId].destroy();
delete guestBubbles[agentId];
}
fetchGuestAgents();
alert((name || agentId) + ' 已离开房间');
} else {
// demo agent 没在后端也允许本地隐藏
if (DEMO_MODE && (name === '尼卡' || name === '水星')) {
hiddenDemoNames.add(name);
removeGuestSpriteByName(name);
renderGuestAgentList();
alert(name + ' 已离开房间(demo)');
return;
}
alert('离开失败:' + (data.msg || '未知错误'));
}
}).catch(error => {
alert('请求失败:' + error);
});
}
function approveGuestAgent(agentId) {
fetch('/agent-approve', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ agentId })
}).then(response => response.json()).then(data => {
if (data.ok) {
fetchGuestAgents();
alert('已批准该访客接入');
} else {
alert('批准失败:' + (data.msg || '未知错误'));
}
}).catch(error => {
alert('请求失败:' + error);
});
}
function rejectGuestAgent(agentId) {
fetch('/agent-reject', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ agentId })
}).then(response => response.json()).then(data => {
if (data.ok) {
fetchGuestAgents();
alert('已拒绝该访客');
} else {
alert('拒绝失败:' + (data.msg || '未知错误'));
}
}).catch(error => {
alert('请求失败:' + error);
});
}
function ensureDemoVisitors() {
if (!DEMO_MODE) return;
if (!Array.isArray(window.__demoVisitors) || window.__demoVisitors.length === 0) {
window.__demoVisitors = [
{ agentId: 'demo_nika', name: '尼卡', authStatus: 'approved', state: 'writing', bubbleText: '我在工作中', isDemo: true, updated_at: new Date().toISOString() },
{ agentId: 'demo_mercury', name: '水星', authStatus: 'approved', state: 'idle', bubbleText: '我去休息区躺一下', isDemo: true, updated_at: new Date().toISOString() }
];
}
}
function getMergedVisitors() {
const realVisitors = (guestAgents || []).filter(a => !a.isMain);
if (!DEMO_MODE) return realVisitors;
ensureDemoVisitors();
const demoVisitors = window.__demoVisitors.filter(v => !hiddenDemoNames.has(v.name));
return [...realVisitors, ...demoVisitors];
}
function renderGuestAgentList() {
const list = document.getElementById('guest-agent-list');
if (!list) return;
const visitors = getMergedVisitors();
if (visitors.length === 0) {
list.innerHTML = '<div style="color:#9ca3af;font-size:12px;text-align:center;padding:20px 0;">暂无访客</div>';
return;
}
list.innerHTML = visitors.map(agent => {
const name = agent.name || '未命名访客';
const authStatus = agent.authStatus || 'pending';
const state = agent.state || 'idle';
const statusMap = {
approved: '已授权',
pending: '待授权',
rejected: '已拒绝',
offline: '离线'
};
const stateMap = {
idle: '待命',
writing: '工作',
researching: '调研',
executing: '执行',
syncing: '同步',
error: '报警'
};
const statusText = statusMap[authStatus] || authStatus;
const stateText = stateMap[state] || state;
const subtitle = `${statusText} · ${stateText}`;
const pendingActions = `<button onclick="alert('交换 skill 功能开发中')">交换skill</button><button class="leave-btn" onclick="leaveGuestAgent('${agent.agentId}','${name}')">离开房间</button>`;
return `
<div class="guest-agent-item" data-name="${name}">
<div>
<div class="guest-agent-name">${name}</div>
<div style="font-size:11px;color:#cbd5e1;">${subtitle}</div>
</div>
<div class="guest-agent-buttons">
${pendingActions}
</div>
</div>
`;
}).join('');
}
function getAreaRect(area) {
// 区域坐标(海辛提供,左上-右下;这里的 x/y 作为 sprite 底部锚点坐标来用)
// 休息区域范围(511,262)(841,621)
// 工作区域范围(190,526)(380,683)
// error 区域范围(932,275)(1109,327)
const rects = {
breakroom: { x1: 511, y1: 262, x2: 841, y2: 621 },
writing: { x1: 190, y1: 526, x2: 380, y2: 683 },
error: { x1: 932, y1: 275, x2: 1109, y2: 327 }
};
return rects[area] || rects.breakroom;
}
function randomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function randomPointInRect(rect) {
return { x: randomInt(rect.x1, rect.x2), y: randomInt(rect.y1, rect.y2) };
}
function getAreaPoint(area, idx) {
// 非 demo 访客:仍用固定点位,避免每次轮询都抖动。
const map = {
breakroom: [
{ x: 511, y: 262 },
{ x: 841, y: 621 },
{ x: 690, y: 470 }
],
writing: [
{ x: 190, y: 526 },
{ x: 380, y: 683 },
{ x: 300, y: 610 }
],
error: [
{ x: 932, y: 275 },
{ x: 1109, y: 327 },
{ x: 1020, y: 305 }
]
};
const arr = map[area] || map.breakroom;
return arr[idx % arr.length];
}
function renderGuestAgentsInScene() {
if (!game) return;
const visitors = getMergedVisitors();
const seenIds = new Set();
let idxBreak = 0, idxWrite = 0, idxError = 0;
visitors.forEach(agent => {
const id = agent.agentId;
seenIds.add(id);
const isDemo = !!agent.isDemo || (DEMO_MODE && (id === 'demo_nika' || id === 'demo_mercury' || agent.name === '尼卡' || agent.name === '水星'));
const area = agent.area || (agent.state === 'error' ? 'error' : (agent.state === 'idle' ? 'breakroom' : 'writing'));
const idx = area === 'breakroom' ? idxBreak++ : area === 'error' ? idxError++ : idxWrite++;
const p = isDemo
? randomPointInRect(getAreaRect(area))
: getAreaPoint(area, idx);
if (!guestSprites[id]) {
// 优先用图标:demo visitor 有专门映射
let sprite;
const isDemoNika = DEMO_MODE && (agent.agentId === 'demo_nika' || agent.name === '尼卡');
const isDemoMercury = DEMO_MODE && (agent.agentId === 'demo_mercury' || agent.name === '水星');
if (isDemoNika && game.textures.exists('demo_mercury')) {
// 交换映射:尼卡使用 demo_mercury
sprite = game.add.image(p.x, p.y, 'demo_mercury').setOrigin(0.5, 1).setScale(1.10);
} else if (isDemoMercury && game.textures.exists('demo_nika')) {
// 交换映射:水星使用 demo_nika
sprite = game.add.image(p.x, p.y, 'demo_nika').setOrigin(0.5, 1).setScale(1.10);
} else if ((agent.name === '尼卡') && game.textures.exists('guestagent1')) {
sprite = game.add.image(p.x, p.y, 'guestagent1').setOrigin(0.5, 1).setScale(1.15);
} else if ((agent.name === '水星') && game.textures.exists('guestagent2')) {
sprite = game.add.image(p.x, p.y, 'guestagent2').setOrigin(0.5, 1).setScale(1.05);
} else {
// 非 demo 访客:优先用动画精灵(guest_anim_x),其次静态图,兜底星星
// 先确定角色索引(1-6)
let animIdx = agent.avatar
? parseInt((agent.avatar.match(/_(\d+)$/) || [])[1] || '0', 10)
: 0;
if (!animIdx || animIdx < 1 || animIdx > 6) {
const aid = String(agent.agentId || '');
let hash = 0;
for (let i = 0; i < aid.length; i++) hash = (hash * 31 + aid.charCodeAt(i)) >>> 0;
animIdx = (hash % 6) + 1;
}
const animKey = `guest_anim_${animIdx}`;
const animIdleKey = `guest_anim_${animIdx}_idle`;
if (game.textures.exists(animKey) && game.anims.exists(animIdleKey)) {
sprite = game.add.sprite(p.x, p.y, animKey).setOrigin(0.5, 1).setScale(4.0);
sprite.anims.play(animIdleKey, true);
} else {
const staticAvatarKey = agent.avatar && game.textures.exists(agent.avatar)
? agent.avatar
: (() => {
const aid = String(agent.agentId || '');
let hash = 0;
for (let i = 0; i < aid.length; i++) hash = (hash * 31 + aid.charCodeAt(i)) >>> 0;
return GUEST_AVATARS[hash % GUEST_AVATARS.length];
})();
if (staticAvatarKey && game.textures.exists(staticAvatarKey)) {
sprite = game.add.image(p.x, p.y, staticAvatarKey).setOrigin(0.5, 1).setScale(1.15);
} else {
sprite = game.add.text(p.x, p.y, '⭐', { fontFamily: 'ArkPixel, monospace', fontSize: '30px' }).setOrigin(0.5, 1);
}
}
}
sprite.setDepth(2600);
if (DEMO_MODE && (agent.agentId === 'demo_mercury' || agent.name === '水星')) {
sprite.y = sprite.y + 10;
}
// demo 水星下移 10px(仅 demo_mercury)
const yOffset = (DEMO_MODE && (agent.agentId === 'demo_mercury' || agent.name === '水星')) ? 10 : 0;
const nameTextY = isDemo ? ((p.y + yOffset) - 80) : ((p.y + yOffset) - 120);
const nameText = game.add.text(p.x, nameTextY, agent.name || '访客', {
fontFamily: 'ArkPixel, monospace',
fontSize: isDemo ? '16px' : '15px',
fill: '#ffffff',
stroke: '#000',
strokeThickness: 3
}).setOrigin(0.5);
nameText.setDepth(2601);
guestSprites[id] = { sprite, nameText };
} else {
const g = guestSprites[id];
const yOffset = (DEMO_MODE && (agent.agentId === 'demo_mercury' || agent.name === '水星')) ? 10 : 0;
// demo:平滑移动(避免闪现);非 demo:保持稳定位置(避免轮询抖动)
if (isDemo) {
// kill previous tweens for this id
if (guestTweens[id] && guestTweens[id].move) {
guestTweens[id].move.stop();
}
if (guestTweens[id] && guestTweens[id].name) {
guestTweens[id].name.stop();
}
const duration = 2000 + Math.floor(Math.random() * 1000); // 2~3s 走路感
const ease = 'Sine.easeInOut';
const moveTween = game.tweens.add({
targets: g.sprite,
x: p.x,
y: p.y + yOffset,
duration,
ease
});
const nameTween = game.tweens.add({
targets: g.nameText,
x: p.x,
y: (p.y + yOffset) - 80,
duration,
ease
});
guestTweens[id] = { move: moveTween, name: nameTween };
} else {
g.sprite.x = p.x;
g.sprite.y = p.y + yOffset;
g.nameText.x = p.x;
g.nameText.y = (p.y + yOffset) - 120;
}
g.nameText.setText(agent.name || '访客');
}
});
// 删除消失的 agent + 清理其气泡/tween
Object.keys(guestSprites).forEach(id => {
if (!seenIds.has(id)) {
guestSprites[id].sprite.destroy();
guestSprites[id].nameText.destroy();
delete guestSprites[id];
if (guestBubbles[id]) {
guestBubbles[id].destroy();
delete guestBubbles[id];
}
if (guestTweens[id]) {
try { guestTweens[id].move && guestTweens[id].move.stop(); } catch(e) {}
try { guestTweens[id].name && guestTweens[id].name.stop(); } catch(e) {}
delete guestTweens[id];
}
}
});
}
function maybeShowGuestBubble(time) {
if (time - lastGuestBubbleAt < 5200) return;
lastGuestBubbleAt = time;
const ids = Object.keys(guestSprites);
if (ids.length === 0) return;
const id = ids[Math.floor(Math.random() * ids.length)];
const g = guestSprites[id];
// demo 气泡:优先展示与状态对应的内容,便于验证“状态→区域→气泡”链路
const demoVisitor = (DEMO_MODE && window.__demoVisitors)
? (window.__demoVisitors.find(v => v.agentId === id) || window.__demoVisitors.find(v => v.name === (g.nameText && g.nameText.text)))
: null;
const statusThoughtsMap = {
idle: ['我在休息区待命', '先放松一下,等下一步任务', '我在休息充电中'],
writing: ['我在工作区处理任务', '正在整理文档与执行中', '工作区专注推进中'],
researching: ['我在调研模式,搜集信息', '正在查资料和验证线索', '研究中,稍后同步结论'],
executing: ['执行中,正在跑流程', '我在工作区推进任务', '正在把计划落地执行'],
syncing: ['同步中,马上更新状态', '正在同步进度到系统', '数据同步中请稍候'],
error: ['我在 bug 区排查问题', '检测到异常,正在修复', '报警中,先定位再处理']
};
const agentState = (guestAgents.find(a => a.agentId === id) || {}).state || 'idle';
const thoughts = statusThoughtsMap[agentState] || statusThoughtsMap.idle;
const text = (demoVisitor && demoVisitor.bubbleText) ? demoVisitor.bubbleText : thoughts[Math.floor(Math.random() * thoughts.length)];
if (guestBubbles[id]) {
guestBubbles[id].destroy();
delete guestBubbles[id];
}
const bx = g.sprite.x;
// 气泡位置:demo 维持原逻辑;真实访客放在“名字上方”,避免压角色也避免压名字
const isDemoGuest = (demoVisitor && demoVisitor.isDemo) || (id === 'demo_nika' || id === 'demo_mercury');
const nameH = (g.nameText && g.nameText.height) ? g.nameText.height : 16;
const by = isDemoGuest ? (g.sprite.y - 90) : ((g.nameText ? g.nameText.y : (g.sprite.y - 150)) - (nameH / 2) - 22);
const fontSize = IS_TOUCH_DEVICE ? 14 : 12;
const bg = game.add.rectangle(bx, by, text.length * 10 + 24, 28, 0xffffff, 0.95);
bg.setStrokeStyle(2, 0x000000);
const txt = game.add.text(bx, by, text, { fontFamily: 'ArkPixel, monospace', fontSize: fontSize + 'px', fill: '#000' }).setOrigin(0.5);
const bubble = game.add.container(0, 0, [bg, txt]);
bubble.setDepth(2700);
guestBubbles[id] = bubble;
// 让气泡跟随 sprite 锚点(用于 demo 平滑移动时也保持贴合)
bubble.__followAgentId = id;
setTimeout(() => {
if (guestBubbles[id]) {
guestBubbles[id].destroy();
delete guestBubbles[id];
}
}, 3200);
}
function maybeRandomizeDemoVisitors() {
if (!DEMO_MODE) return;
ensureDemoVisitors();
// 按海辛需求:每 8 秒切换一次状态
window.__demoNextAt = window.__demoNextAt || 0;
const now = Date.now();
if (now < window.__demoNextAt) return;
window.__demoNextAt = now + 8000;
const states = ['idle', 'writing', 'researching', 'executing', 'syncing', 'error'];
const bubbleTextMap = {
idle: '我去休息区躺一下',
writing: '我在工作中',
researching: '我在调研中',
executing: '我在执行任务',
syncing: '我在同步状态',
error: '出错了!我去报警区'
};
// 确保两位 demo 角色不会总是同一个状态(增加可观测性)
const pickJs = (exclude) => {
let s = states[Math.floor(Math.random() * states.length)];
let tries = 0;
while (exclude && s === exclude && tries < 5) {
s = states[Math.floor(Math.random() * states.length)];
tries++;
}
return s;
};
const current = window.__demoVisitors || [];
const cur0 = current[0] ? (current[0].state || 'idle') : 'idle';
const next0 = pickJs(cur0);
const next1 = pickJs(next0); // 尽量不同
const nextStates = [next0, next1];
const prevVisitors = current.map((v) => ({ ...v }));
window.__demoVisitors = current.map((v, i) => {
const nextState = nextStates[i] || pickJs(v.state);
return {
...v,
state: nextState,
bubbleText: bubbleTextMap[nextState] || String(nextState),
updated_at: new Date().toISOString()
};
});
// 状态切换时:每一位 demo 都立即冒泡(强制),用于清晰验证链路
try {
if (typeof game !== 'undefined' && game) {
// 找出状态实际变了的 demo visitor,给他们强制冒泡
const prevById = {};
prevVisitors.forEach(v => { prevById[v.agentId] = v; });
const newVisitors = window.__demoVisitors || [];
newVisitors.forEach(agent => {
const prev = prevById[agent.agentId];
const changed = !prev || prev.state !== agent.state;
if (changed) {
// 直接冒泡
if (guestSprites[agent.agentId]) {
const g = guestSprites[agent.agentId];
const text = agent.bubbleText || '';
if (guestBubbles[agent.agentId]) {
guestBubbles[agent.agentId].destroy();
delete guestBubbles[agent.agentId];
}
const bx = g.sprite.x;
const by = g.sprite.y - 90;
const fontSize = IS_TOUCH_DEVICE ? 14 : 12;
const bg = game.add.rectangle(bx, by, text.length * 10 + 24, 28, 0xffffff, 0.95);
bg.setStrokeStyle(2, 0x000000);
const txt = game.add.text(bx, by, text, { fontFamily: 'ArkPixel, monospace', fontSize: fontSize + 'px', fill: '#000' }).setOrigin(0.5);
const bubble = game.add.container(0, 0, [bg, txt]);
bubble.setDepth(2700);
bubble.__followAgentId = agent.agentId;
guestBubbles[agent.agentId] = bubble;
setTimeout(() => {
if (guestBubbles[agent.agentId]) {
guestBubbles[agent.agentId].destroy();
delete guestBubbles[agent.agentId];
}
}, 3200);
}
}
});
}
} catch (e) { console.error('强制冒泡失败:', e); }
}
function fetchGuestAgents() {
// demo 随机状态先更新(不依赖后端)
maybeRandomizeDemoVisitors();
return fetch('/agents?t=' + Date.now(), { cache: 'no-store' })
.then(response => response.json())
.then(data => {
// 无论后端返回是否为数组,demo=1 都应保证本地 demo 访客可见
guestAgents = Array.isArray(data) ? data : [];
// 新访客检测:触发 Star 欢迎气泡(只欢迎真实访客,不欢迎 demo)
try {
const merged = getMergedVisitors();
const currentIds = new Set((merged || []).filter(a => !a.isMain && !a.isDemo).map(a => a.agentId));
if (!guestWelcomeInitialized) {
// 首次初始化不欢迎,避免刷新页面就刷屏
lastSeenGuestIds = currentIds;
guestWelcomeInitialized = true;
} else {
const newIds = [];
currentIds.forEach(id => { if (!lastSeenGuestIds.has(id)) newIds.push(id); });
if (newIds.length > 0) {
// 只欢迎第一个新来的(避免同一时刻多人加入刷屏)
const newAgent = (merged || []).find(a => a.agentId === newIds[0]);
if (newAgent && newAgent.name) {
// 临时将 currentState 视为 writing 以允许 showBubble 展示
const oldState = currentState;
currentState = 'writing';
// 临时更换 bubble 文案
const oldTexts = BUBBLE_TEXTS.writing;
BUBBLE_TEXTS.writing = [
`欢迎 ${newAgent.name} 来到办公室~`,
`Hi ${newAgent.name},一起开工吧`,
`${newAgent.name} 已加入,欢迎!`
];
showBubble();
// 还原
BUBBLE_TEXTS.writing = oldTexts;
currentState = oldState;
}
}
lastSeenGuestIds = currentIds;
}
} catch (e) { /* ignore */ }
renderGuestAgentList();
renderGuestAgentsInScene();
})
.catch(error => {
console.error('拉取访客列表失败:', error);
// 即使拉取失败,demo 也要能渲染
if (DEMO_MODE) {
renderGuestAgentList();
renderGuestAgentsInScene();
}
});
}
// 初始化:先检测 WebP 支持,再启动游戏
async function initGame() {
// 检测 WebP 支持
try {
supportsWebP = await checkWebPSupport();
} catch (e) {
try {
supportsWebP = await checkWebPSupportFallback();
} catch (e2) {
supportsWebP = false;
}
}
console.log('WebP 支持:', supportsWebP);
// 启动 Phaser 游戏
new Phaser.Game(config);
}
function preload() {
// 获取加载界面元素
loadingOverlay = document.getElementById('loading-overlay');
loadingProgressBar = document.getElementById('loading-progress-bar');
loadingText = document.getElementById('loading-text');
loadingProgressContainer = document.getElementById('loading-progress-container');
// 设置资源总数(全部首屏加载:装饰也第一时间出现)
totalAssets = 15;
loadedAssets = 0;
// 加载进度监听
this.load.on('filecomplete', () => {
updateLoadingProgress();
});
this.load.on('complete', () => {
hideLoadingOverlay();
});
// cache-busting to avoid stale background on client/CDN
// use smaller/new map version provided by user
this.load.image('office_bg', '/static/office_bg_small' + (supportsWebP ? '.webp' : '.png') + '?v={{VERSION_TIMESTAMP}}');
this.load.spritesheet('star_idle', '/static/star-idle-spritesheet' + getExt('star-idle-spritesheet.png'), { frameWidth: 128, frameHeight: 128 });
this.load.spritesheet('star_researching', '/static/star-researching-spritesheet' + getExt('star-researching-spritesheet.png'), { frameWidth: 128, frameHeight: 105 });
// Furniture
this.load.image('sofa_idle', '/static/sofa-idle' + getExt('sofa-idle.png'));
this.load.spritesheet('sofa_busy', '/static/sofa-busy-spritesheet' + getExt('sofa-busy-spritesheet.png'), { frameWidth: 256, frameHeight: 256 });
// Decor
this.load.spritesheet('plants', '/static/plants-spritesheet' + getExt('plants-spritesheet.png'), { frameWidth: 160, frameHeight: 160 });
this.load.spritesheet('posters', '/static/posters-spritesheet' + getExt('posters-spritesheet.png'), { frameWidth: 160, frameHeight: 160 });
this.load.spritesheet('coffee_machine', '/static/coffee-machine-spritesheet' + getExt('coffee-machine-spritesheet.png'), { frameWidth: 230, frameHeight: 230 });
this.load.spritesheet('serverroom', '/static/serverroom-spritesheet' + getExt('serverroom-spritesheet.png'), { frameWidth: 180, frameHeight: 251 });
// Error / bug animation: 180x180, 96 frames (repacked grid)
this.load.spritesheet('error_bug', '/static/error-bug-spritesheet-grid' + (supportsWebP ? '.webp' : '.png'), { frameWidth: 180, frameHeight: 180 });
// Cat spritesheet: 160x160, 4x4=16 cats
this.load.spritesheet('cats', '/static/cats-spritesheet' + (supportsWebP ? '.webp' : '.png'), { frameWidth: 160, frameHeight: 160 });
// Desk
this.load.image('desk', '/static/desk' + getExt('desk.png'));
// Star working animation: repacked to grid to avoid WebGL max texture size limits
// NOTE: prefer WebP for size, PNG fallback
this.load.spritesheet('star_working', '/static/star-working-spritesheet-grid' + (supportsWebP ? '.webp' : '.png'), { frameWidth: 230, frameHeight: 144 });
// Sync state animation (256x256, 53 frames)
this.load.spritesheet('sync_anim', '/static/sync-animation-spritesheet-grid' + (supportsWebP ? '.webp' : '.png'), { frameWidth: 256, frameHeight: 256 });
// Memo background image
this.load.image('memo_bg', '/static/memo-bg' + (supportsWebP ? '.webp' : '.png'));
// New desk image (v2) - force transparent PNG
this.load.image('desk_v2', '/static/desk-v2.png');
// Flower spritesheet (65x65, 16 frames)
this.load.spritesheet('flowers', '/static/flowers-spritesheet' + (supportsWebP ? '.webp' : '.png'), { frameWidth: 65, frameHeight: 65 });
// Guest/Demo agent sprites
this.load.image('guestagent1', '/static/guestagent1.webp?v={{VERSION_TIMESTAMP}}');
this.load.image('guestagent2', '/static/guestagent2.webp?v={{VERSION_TIMESTAMP}}');
this.load.spritesheet('guest_anim_1', '/static/guest_anim_1.webp?v={{VERSION_TIMESTAMP}}', { frameWidth: 32, frameHeight: 32 });
this.load.spritesheet('guest_anim_2', '/static/guest_anim_2.webp?v={{VERSION_TIMESTAMP}}', { frameWidth: 32, frameHeight: 32 });
this.load.spritesheet('guest_anim_3', '/static/guest_anim_3.webp?v={{VERSION_TIMESTAMP}}', { frameWidth: 32, frameHeight: 32 });
this.load.spritesheet('guest_anim_4', '/static/guest_anim_4.webp?v={{VERSION_TIMESTAMP}}', { frameWidth: 32, frameHeight: 32 });
this.load.spritesheet('guest_anim_5', '/static/guest_anim_5.webp?v={{VERSION_TIMESTAMP}}', { frameWidth: 32, frameHeight: 32 });
this.load.spritesheet('guest_anim_6', '/static/guest_anim_6.webp?v={{VERSION_TIMESTAMP}}', { frameWidth: 32, frameHeight: 32 });
this.load.image('demo_nika', '/static/demo_nika.webp?v={{VERSION_TIMESTAMP}}');
this.load.image('demo_mercury', '/static/demo_mercury.webp?v={{VERSION_TIMESTAMP}}');
}
function create() {
game = this;
this.add.image(640, 360, 'office_bg');
// Place furniture: Sofa
// NOTE: coordinates are interpreted as the TOP-LEFT corner of the sprite
sofa = this.add.sprite(670, 144, 'sofa_busy').setOrigin(0, 0);
sofa.setDepth(10);
this.anims.create({
key: 'sofa_busy',
frames: this.anims.generateFrameNumbers('sofa_busy', { start: 0, end: 47 }),
frameRate: 12,
repeat: -1
});
areas = {
door: { x: 640, y: 550 }, // 墙的门(偏下 1/3 位置)
writing: { x: 320, y: 360 }, // 左 1/3 中间(办公桌)
researching: { x: 320, y: 360 }, // 左 1/3 中间(研究也在办公区
error: { x: 1066, y: 180 }, // 右 1/3 上 1/2(服务器区
breakroom: { x: 640, y: 360 } // 中间 1/3(沙发
};
// 创建 Star 角色精灵动画
this.anims.create({
key: 'star_idle',
frames: this.anims.generateFrameNumbers('star_idle', { start: 0, end: 29 }),
frameRate: 12,
repeat: -1
});
this.anims.create({
key: 'star_researching',
frames: this.anims.generateFrameNumbers('star_researching', { start: 0, end: 95 }),
frameRate: 12,
repeat: -1
});
// 创建 6 个访客角色的循环 idle 动画(8帧循环)
for (let i = 1; i <= 6; i++) {
this.anims.create({
key: `guest_anim_${i}_idle`,
frames: this.anims.generateFrameNumbers(`guest_anim_${i}`, { start: 0, end: 7 }),
frameRate: 8,
repeat: -1
});
}
star = game.physics.add.sprite(areas.breakroom.x, areas.breakroom.y, 'star_idle');
star.setOrigin(0.5);
star.setScale(1.4);
star.setAlpha(0.95);
star.setDepth(20); // Put Star on top of everything
// Default: idle is represented by the sofa animation, so hide Star until non-idle state
star.setVisible(false);
star.anims.stop();
// Start sofa animation (default idle = sofa sitting)
if (game.textures.exists('sofa_busy')) {
sofa.setTexture('sofa_busy');
sofa.anims.play('sofa_busy', true);
}
// 加像素风小牌匾:海辛小龙虾的办公室
const plaqueX = config.width / 2;
const plaqueY = config.height - 36;
const plaqueBg = game.add.rectangle(plaqueX, plaqueY, 420, 44, 0x5d4037);
plaqueBg.setStrokeStyle(3, 0x3e2723);
const plaqueText = game.add.text(plaqueX, plaqueY, '海辛小龙虾的办公室', {
fontFamily: 'ArkPixel, monospace',
fontSize: '18px',
fill: '#ffd700',
fontWeight: 'bold',
stroke: '#000',
strokeThickness: 2
}).setOrigin(0.5);
// 牌匾两边加个小装饰(跟随牌匾居中)
game.add.text(plaqueX - 190, plaqueY, '⭐', { fontFamily: 'ArkPixel, monospace', fontSize: '20px' }).setOrigin(0.5);
game.add.text(plaqueX + 190, plaqueY, '⭐', { fontFamily: 'ArkPixel, monospace', fontSize: '20px' }).setOrigin(0.5);
// Random plant at (565,178) (frame 0-15, 160x160 each)
const plantFrameCount = 16;
const randomPlantFrame = Math.floor(Math.random() * plantFrameCount);
const plant = game.add.sprite(565, 178, 'plants', randomPlantFrame).setOrigin(0.5);
plant.setDepth(5);
plant.setInteractive({ useHandCursor: true });
// Expose to global for click handler
window.plantSprite = plant;
window.plantFrameCount = plantFrameCount;
plant.on('pointerdown', () => {
const next = Math.floor(Math.random() * window.plantFrameCount);
window.plantSprite.setFrame(next);
});
// Random plant at (230,185) (frame 0-15, 160x160 each)
const plant2Frame = Math.floor(Math.random() * plantFrameCount);
const plant2 = game.add.sprite(230, 185, 'plants', plant2Frame).setOrigin(0.5);
plant2.setDepth(5);
plant2.setInteractive({ useHandCursor: true });
// Expose to global for click handler
window.plantSprite2 = plant2;
plant2.on('pointerdown', () => {
const next = Math.floor(Math.random() * window.plantFrameCount);
window.plantSprite2.setFrame(next);
});
// Random plant at (977,496) (frame 0-15, 160x160 each)
const plant3Frame = Math.floor(Math.random() * plantFrameCount);
const plant3 = game.add.sprite(977, 496, 'plants', plant3Frame).setOrigin(0.5);
plant3.setDepth(5);
plant3.setInteractive({ useHandCursor: true });
// Expose to global for click handler
window.plantSprite3 = plant3;
plant3.on('pointerdown', () => {
const next = Math.floor(Math.random() * window.plantFrameCount);
window.plantSprite3.setFrame(next);
});
// Random poster at (252,66) (random frame from spritesheet)
const postersFrameCount = 32; // 640x1280 with 160x160 frames => 4x8 = 32
const randomPosterFrame = Math.floor(Math.random() * postersFrameCount);
const poster = game.add.sprite(252, 66, 'posters', randomPosterFrame).setOrigin(0.5);
poster.setDepth(4);
poster.setInteractive({ useHandCursor: true });
// Expose to global for click handler
window.posterSprite = poster;
window.posterFrameCount = postersFrameCount;
poster.on('pointerdown', () => {
const next = Math.floor(Math.random() * window.posterFrameCount);
window.posterSprite.setFrame(next);
});
// Random cat at (94,557) (frame 0-15, 160x160 each)
const catsFrameCount = 16; // 4x4 grid
const randomCatFrame = Math.floor(Math.random() * catsFrameCount);
const cat = game.add.sprite(94, 557, 'cats', randomCatFrame).setOrigin(0.5);
cat.setDepth(2000); // top layer
cat.setInteractive({ useHandCursor: true });
// Expose to global for click handler
window.catSprite = cat;
window.catsFrameCount = catsFrameCount;
cat.on('pointerdown', () => {
const next = Math.floor(Math.random() * window.catsFrameCount);
window.catSprite.setFrame(next);
});
// Coffee machine at (563,330)
this.anims.create({
key: 'coffee_machine',
frames: this.anims.generateFrameNumbers('coffee_machine', { start: 0, end: 95 }),
frameRate: 12.5,
repeat: -1
});
const coffeeMachine = this.add.sprite(659, 397, 'coffee_machine').setOrigin(0.5);
coffeeMachine.setDepth(99);
coffeeMachine.anims.play('coffee_machine', true);
// Server room animation
this.anims.create({
key: 'serverroom_on',
frames: this.anims.generateFrameNumbers('serverroom', { start: 0, end: 39 }),
frameRate: 6,
repeat: -1
});
serverroom = this.add.sprite(1021, 142, 'serverroom', 0).setOrigin(0.5);
serverroom.setDepth(2);
// 默认 idle: 静止第0帧
serverroom.anims.stop();
serverroom.setFrame(0);
// Desk at (218,417) (v2)
const desk = this.add.image(218, 417, 'desk_v2').setOrigin(0.5);
desk.setDepth(1001); // desk above starWorking
// Random flower pot at (310,405) (65x65 frames, top layer)
const flowerFrameCount = 16; // 4x4 65x65 frames
const randomFlowerFrame = Math.floor(Math.random() * flowerFrameCount);
const flower = this.add.sprite(310, 405, 'flowers', randomFlowerFrame).setOrigin(0.5);
flower.setDepth(1100); // highest among desk/starWorking
flower.setInteractive({ useHandCursor: true });
window.flowerSprite = flower;
window.flowerFrameCount = flowerFrameCount;
flower.on('pointerdown', () => {
const next = Math.floor(Math.random() * window.flowerFrameCount);
window.flowerSprite.setFrame(next);
});
// Star working at desk (217,333)
this.anims.create({
key: 'star_working',
frames: this.anims.generateFrameNumbers('star_working', { start: 0, end: 191 }),
frameRate: 12,
repeat: -1
});
// Error / bug animation (96 frames)
this.anims.create({
key: 'error_bug',
frames: this.anims.generateFrameNumbers('error_bug', { start: 0, end: 95 }),
frameRate: 12,
repeat: -1
});
// Error bug character (moves between two points when state=error)
const errorBug = this.add.sprite(1007, 221, 'error_bug', 0).setOrigin(0.5);
errorBug.setDepth(50); // above serverroom, below desk/bubbles
errorBug.setVisible(false);
errorBug.setScale(0.9); // shrink 10%
errorBug.anims.play('error_bug', true);
window.errorBug = errorBug;
window.errorBugDir = 1; // 1 -> to right, -1 -> to left
const starWorking = this.add.sprite(217, 333, 'star_working', 0).setOrigin(0.5);
starWorking.setVisible(false);
starWorking.setScale(1.32); // +10% (from 1.2 → 1.32)
starWorking.setDepth(900); // starWorking under desk so desk partially covers it
// Store reference to starWorking for state logic
window.starWorking = starWorking;
// Sync animation sprite at (1157,592)
this.anims.create({
key: 'sync_anim',
frames: this.anims.generateFrameNumbers('sync_anim', { start: 1, end: 52 }),
frameRate: 12,
repeat: -1
});
syncAnimSprite = this.add.sprite(1157, 592, 'sync_anim', 0).setOrigin(0.5);
syncAnimSprite.setDepth(40);
// default show first frame only
syncAnimSprite.anims.stop();
syncAnimSprite.setFrame(0);
// Debug: expose star sprite too (for path calibration / visuals)
window.starSprite = star;
statusText = document.getElementById('status-text');
coordsOverlay = document.getElementById('coords-overlay');
coordsDisplay = document.getElementById('coords-display');
coordsToggle = document.getElementById('coords-toggle');
// guest agent 将由 /agents 动态拉取并渲染到右侧访客列表
coordsToggle.addEventListener('click', () => {
showCoords = !showCoords;
coordsOverlay.style.display = showCoords ? 'block' : 'none';
coordsToggle.textContent = showCoords ? '隐藏坐标' : '显示坐标';
coordsToggle.style.background = showCoords ? '#e94560' : '#333';
});
// 允许手机端“拖动/滑动”来移动视野(本质:移动 Phaser Camera)
// iPhone 等触屏设备默认开启;桌面端默认关闭(可手动开)。
const panToggle = document.getElementById('pan-toggle');
const isTouchDevice = IS_TOUCH_DEVICE;
let panEnabled = false;
let isPanning = false;
let panStart = null; // {x,y,sx,sy}
const camera = game.cameras.main;
const MAP_W = config.width;
const MAP_H = config.height;
function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }
function maxScrollX() {
const viewportW = camera.width / Math.max(0.01, camera.zoom);
return Math.max(0, MAP_W - viewportW);
}
function maxScrollY() {
const viewportH = camera.height / Math.max(0.01, camera.zoom);
return Math.max(0, MAP_H - viewportH);
}
function clampCameraScroll() {
camera.scrollX = clamp(camera.scrollX, 0, maxScrollX());
camera.scrollY = clamp(camera.scrollY, 0, maxScrollY());
}
// 手机上:锁定“办公室画布高度 = 2/3 区域高度”,
// 让世界坐标在竖向恰好看满(不需要上下拖),只保留横向拖动浏览左右。
// 记录初始是否已手动平移(避免 resize 时把用户拖好的位置重置)
let hasManuallyPanned = false;
function applyMobileCameraFit() {
if (!isTouchDevice) return;
const h = Math.max(1, camera.height);
const w = Math.max(1, camera.width);
// 关键:先按高度 fit,再看是否需要按宽度微调,
// 保证既不会让画面歪,又能左右拖到最左最右边缘不被裁。
const fitHeightZoom = h / MAP_H;
const candidateZoom = fitHeightZoom;
// 按 candidateZoom 计算:viewport 在世界坐标里的宽高
const viewW = w / candidateZoom;
const maxX = Math.max(0, MAP_W - viewW);
camera.setZoom(candidateZoom);
camera.scrollX = Math.min(camera.scrollX, maxX);
camera.scrollY = 0;
// 仅在未手动平移过时才居中(避免把用户拖好的位置冲掉)
if (!hasManuallyPanned) {
camera.centerOn(MAP_W / 2, MAP_H / 2);
}
camera.scrollX = clamp(camera.scrollX, 0, maxX);
camera.scrollY = 0;
}
applyMobileCameraFit();
// 手机端旋转屏幕/地址栏伸缩时,重算 zoom + 夹紧 camera
if (isTouchDevice && game.scale) {
game.scale.on('resize', () => {
applyMobileCameraFit();
});
}
camera.setBounds(0, 0, MAP_W, MAP_H);
clampCameraScroll();
function setPanEnabled(on) {
panEnabled = on;
if (panToggle) {
panToggle.textContent = on ? '锁定视野' : '移动视野';
panToggle.style.background = on ? '#e94560' : '#333';
}
game.input.setDefaultCursor(on ? 'grab' : 'default');
}
if (panToggle) {
panToggle.addEventListener('click', () => setPanEnabled(!panEnabled));
}
// 触屏设备默认开启拖动视野,避免必须先点按钮
if (isTouchDevice) {
setPanEnabled(true);
}
// iOS/Safari 手势策略:
// - 保留垂直滚动(让页面能下滑看三个面板)
// - 水平方向拖动时才阻止默认行为,并转为 camera 横向平移
// 说明:iOS 对 pointer + touch-action 支持存在机型差异,所以这里加一套原生 touch 兜底。
const canvasEl = game.canvas;
let touchPan = null; // {x,y,sx,sy,lock:'x'|'y'|null}
if (canvasEl) {
canvasEl.style.touchAction = 'pan-y';
canvasEl.addEventListener('touchstart', (e) => {
if (!panEnabled || e.touches.length !== 1) return;
const t = e.touches[0];
touchPan = { x: t.clientX, y: t.clientY, sx: camera.scrollX, sy: camera.scrollY, lock: null };
}, { passive: true });
canvasEl.addEventListener('touchmove', (e) => {
if (!panEnabled || !touchPan || e.touches.length !== 1) return;
const t = e.touches[0];
const dx = t.clientX - touchPan.x;
const dy = t.clientY - touchPan.y;
if (!touchPan.lock) {
if (Math.abs(dx) < 6 && Math.abs(dy) < 6) return;
touchPan.lock = Math.abs(dx) >= Math.abs(dy) ? 'x' : 'y';
}
if (touchPan.lock === 'x') {
// 横向拖动交给办公室视野;阻止浏览器默认滚动
e.preventDefault();
hasManuallyPanned = true;
camera.scrollX = clamp(touchPan.sx - dx, 0, maxScrollX());
}
// lock==='y' 时不阻止默认,交给页面纵向滚动
}, { passive: false });
const clearTouchPan = () => { touchPan = null; };
canvasEl.addEventListener('touchend', clearTouchPan, { passive: true });
canvasEl.addEventListener('touchcancel', clearTouchPan, { passive: true });
}
game.input.on('pointerdown', (pointer) => {
if (!panEnabled) return;
isPanning = true;
panStart = { x: pointer.x, y: pointer.y, sx: camera.scrollX, sy: camera.scrollY };
game.input.setDefaultCursor('grabbing');
});
game.input.on('pointerup', () => {
if (!panEnabled) return;
isPanning = false;
panStart = null;
game.input.setDefaultCursor('grab');
});
game.input.on('pointermove', (pointer) => {
if (!panEnabled || !isPanning || !panStart) return;
const dx = pointer.x - panStart.x;
const dy = pointer.y - panStart.y;
// 手机端优先“横向拖动看办公室”,纵向手势留给页面滚动看下方面板。
if (isTouchDevice && Math.abs(dy) > Math.abs(dx)) {
return;
}
// 手指向右拖,视野跟着向右看:camera scroll 向左减小(反向)
const newX = panStart.sx - dx;
hasManuallyPanned = true;
camera.scrollX = clamp(newX, 0, maxScrollX());
// 桌面端保留自由二维拖动
if (!isTouchDevice) {
const newY = panStart.sy - dy;
camera.scrollY = clamp(newY, 0, maxScrollY());
}
});
// Mouse move handler for coordinate display
game.input.on('pointermove', (pointer) => {
if (!showCoords) return;
// Clamp to map size (0..width-1 / 0..height-1)
const x = Math.max(0, Math.min(config.width - 1, Math.round(pointer.x)));
const y = Math.max(0, Math.min(config.height - 1, Math.round(pointer.y)));
coordsDisplay.textContent = `${x}, ${y}`;
// Position overlay next to mouse
coordsOverlay.style.left = (pointer.x + 18) + 'px';
coordsOverlay.style.top = (pointer.y + 18) + 'px';
});
// 加载昨日 memo
loadMemo();
fetchStatus();
fetchGuestAgents();
}
function update(time) {
if (time - lastFetch > FETCH_INTERVAL) { fetchStatus(); lastFetch = time; }
if (time - lastGuestAgentsFetch > GUEST_AGENTS_FETCH_INTERVAL) { fetchGuestAgents(); lastGuestAgentsFetch = time; }
// 兜底:非 idle 时确保机房动画在播,idle 时静止
const effectiveStateForServer = pendingDesiredState || currentState;
if (serverroom) {
if (effectiveStateForServer === 'idle') {
if (serverroom.anims.isPlaying) {
serverroom.anims.stop();
serverroom.setFrame(0);
}
} else {
if (!serverroom.anims.isPlaying || serverroom.anims.currentAnim?.key !== 'serverroom_on') {
serverroom.anims.play('serverroom_on', true);
}
}
}
// error 状态:显示 bug 动画,并在两点之间来回移动
if (window.errorBug) {
if (effectiveStateForServer === 'error') {
window.errorBug.setVisible(true);
if (!window.errorBug.anims.isPlaying || window.errorBug.anims.currentAnim?.key !== 'error_bug') {
window.errorBug.anims.play('error_bug', true);
}
// ping-pong move between x=1007 and x=1111 at y=221
const leftX = 1007;
const rightX = 1111;
const speed = 0.6; // -50% speed
const dir = window.errorBugDir || 1;
window.errorBug.x += speed * dir;
window.errorBug.y = 221;
if (window.errorBug.x >= rightX) {
window.errorBug.x = rightX;
window.errorBugDir = -1;
} else if (window.errorBug.x <= leftX) {
window.errorBug.x = leftX;
window.errorBugDir = 1;
}
} else {
window.errorBug.setVisible(false);
window.errorBug.anims.stop();
}
}
// Sync animation fallback logic
if (syncAnimSprite) {
if (effectiveStateForServer === 'syncing') {
if (!syncAnimSprite.anims.isPlaying || syncAnimSprite.anims.currentAnim?.key !== 'sync_anim') {
syncAnimSprite.anims.play('sync_anim', true);
}
} else {
if (syncAnimSprite.anims.isPlaying) syncAnimSprite.anims.stop();
syncAnimSprite.setFrame(0);
}
}
// 冒气泡
if (time - lastBubble > BUBBLE_INTERVAL) {
showBubble();
lastBubble = time;
}
// 猫的气泡(频率低)
if (time - lastCatBubble > CAT_BUBBLE_INTERVAL) {
showCatBubble();
lastCatBubble = time;
}
// 打字机效果
if (typewriterIndex < typewriterTarget.length && time - lastTypewriter > TYPEWRITER_DELAY) {
typewriterText += typewriterTarget[typewriterIndex];
statusText.textContent = typewriterText;
typewriterIndex++;
lastTypewriter = time;
}
// 移动 + 小踱步
moveStar(time);
// guest 随机想法泡泡
maybeShowGuestBubble(time);
// demo 平滑移动时:让气泡每帧跟随角色锚点(避免 tween 时气泡滞留在旧位置)
try {
Object.keys(guestBubbles).forEach(id => {
const b = guestBubbles[id];
const g = guestSprites[id];
if (!b || !g) return;
if (b.__followAgentId !== id) return;
b.x = 0;
b.y = 0;
// children[0]=bg, children[1]=text
const bx = g.sprite.x;
const isDemoGuest = (id === 'demo_nika' || id === 'demo_mercury');
const nameH = (g.nameText && g.nameText.height) ? g.nameText.height : 16;
const by = isDemoGuest ? (g.sprite.y - 90) : ((g.nameText ? g.nameText.y : (g.sprite.y - 150)) - (nameH / 2) - 22);
if (b.list && b.list[0]) { b.list[0].x = bx; b.list[0].y = by; }
if (b.list && b.list[1]) { b.list[1].x = bx; b.list[1].y = by; }
});
} catch (e) {}
// guest 列表会定时刷新
}
function normalizeState(s) {
if (!s) return 'idle';
if (s === 'working') return 'writing';
if (s === 'run' || s === 'running') return 'executing';
if (s === 'sync') return 'syncing';
if (s === 'research') return 'researching';
return s;
}
function fetchStatus() {
fetch('/status')
.then(response => response.json())
.then(data => {
const nextState = normalizeState(data.state);
const stateInfo = STATES[nextState] || STATES.idle;
// If we're mid-transition, don't restart the path every poll
const changed = (pendingDesiredState === null) && (nextState !== currentState);
const nextLine = '[' + stateInfo.name + '] ' + (data.detail || '...');
if (changed) {
typewriterTarget = nextLine;
typewriterText = '';
typewriterIndex = 0;
// Set state immediately (no waypoints/path movement)
pendingDesiredState = null;
currentState = nextState;
// Idle: sofa animation
if (nextState === 'idle') {
// Sofa
if (game.textures.exists('sofa_busy')) {
sofa.setTexture('sofa_busy');
sofa.anims.play('sofa_busy', true);
}
// Hide moving star, hide desk star
star.setVisible(false);
star.anims.stop();
if (window.starWorking) {
window.starWorking.setVisible(false);
window.starWorking.anims.stop();
}
} else if (nextState === 'error') {
// Error: no working animation at desk
sofa.anims.stop();
sofa.setTexture('sofa_idle');
star.setVisible(false);
star.anims.stop();
if (window.starWorking) {
window.starWorking.setVisible(false);
window.starWorking.anims.stop();
}
} else if (nextState === 'syncing') {
// Syncing: also no working animation at desk
sofa.anims.stop();
sofa.setTexture('sofa_idle');
star.setVisible(false);
star.anims.stop();
if (window.starWorking) {
window.starWorking.setVisible(false);
window.starWorking.anims.stop();
}
} else {
// Non-idle non-error: starWorking animation at desk
sofa.anims.stop();
sofa.setTexture('sofa_idle');
// Hide moving star, show desk star
star.setVisible(false);
star.anims.stop();
if (window.starWorking) {
window.starWorking.setVisible(true);
window.starWorking.anims.play('star_working', true);
}
}
// Server room logic:
if (serverroom) {
if (nextState === 'idle') {
serverroom.anims.stop();
serverroom.setFrame(0);
} else {
serverroom.anims.play('serverroom_on', true);
}
}
// Sync animation logic:
// default: frame 0
// state=syncing: play from frame 1
if (syncAnimSprite) {
if (nextState === 'syncing') {
if (!syncAnimSprite.anims.isPlaying || syncAnimSprite.anims.currentAnim?.key !== 'sync_anim') {
syncAnimSprite.anims.play('sync_anim', true);
}
} else {
syncAnimSprite.anims.stop();
syncAnimSprite.setFrame(0);
}
}
} else {
if (!typewriterTarget || typewriterTarget !== nextLine) {
typewriterTarget = nextLine;
typewriterText = '';
typewriterIndex = 0;
}
}
})
.catch(error => {
typewriterTarget = '连接失败,正在重试...';
typewriterText = '';
typewriterIndex = 0;
});
}
function moveStar(time) {
// Use pending state if available (for target area during transition)
const effectiveState = pendingDesiredState || currentState;
const stateInfo = STATES[effectiveState] || STATES.idle;
const baseTarget = areas[stateInfo.area] || areas.breakroom;
const dx = targetX - star.x;
const dy = targetY - star.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const speed = 1.4;
const wobble = Math.sin(time / 200) * 0.8;
if (dist > 3) {
// Move toward current target
star.x += (dx / dist) * speed;
star.y += (dy / dist) * speed;
star.setY(star.y + wobble);
isMoving = true;
} else {
// Arrived at a waypoint or final target
if (waypoints && waypoints.length > 0) {
// Remove the first waypoint (we just arrived there)
waypoints.shift();
if (waypoints.length > 0) {
// Next waypoint exists
targetX = waypoints[0].x;
targetY = waypoints[0].y;
isMoving = true;
} else {
// Final target: apply pending state and switch visual
if (pendingDesiredState !== null) {
isMoving = false;
currentState = pendingDesiredState;
pendingDesiredState = null;
if (currentState === 'idle') {
// Sitting on sofa: hide Star only when arrived
star.setVisible(false);
star.anims.stop();
if (window.starWorking) {
window.starWorking.setVisible(false);
window.starWorking.anims.stop();
}
} else {
// Arrived at desk area: switch to star_working animation
star.setVisible(false);
star.anims.stop();
if (window.starWorking) {
window.starWorking.setVisible(true);
window.starWorking.anims.play('star_working', true);
}
}
}
}
} else {
if (pendingDesiredState !== null) {
isMoving = false;
currentState = pendingDesiredState;
pendingDesiredState = null;
if (currentState === 'idle') {
star.setVisible(false);
star.anims.stop();
if (window.starWorking) {
window.starWorking.setVisible(false);
window.starWorking.anims.stop();
}
if (game.textures.exists('sofa_busy')) {
sofa.setTexture('sofa_busy');
sofa.anims.play('sofa_busy', true);
}
} else {
// Arrived at desk area: switch to star_working animation
star.setVisible(false);
star.anims.stop();
if (window.starWorking) {
window.starWorking.setVisible(true);
window.starWorking.anims.play('star_working', true);
}
sofa.anims.stop();
sofa.setTexture('sofa_idle');
}
}
}
}
// Small wander only after arrival (non-idle)
// Temporarily disabled to stay in work area; uncomment later if needed
/*
if (!isMoving && currentState !== 'idle' && pendingDesiredState === null && (time - lastWanderAt) > 3500) {
targetX = baseTarget.x + (Math.random() - 0.5) * 60;
targetY = baseTarget.y + (Math.random() - 0.5) * 40;
star.setVisible(true);
star.anims.play('star_idle', true);
isMoving = true;
lastWanderAt = time;
}
*/
}
function showBubble() {
if (bubble) { bubble.destroy(); bubble = null; }
const texts = BUBBLE_TEXTS[currentState] || BUBBLE_TEXTS.idle;
if (currentState === 'idle') return; // no bubble while sitting on sofa
// Bubble anchor should follow current visible character:
// - syncing: syncAnimSprite
// - error state: errorBug
// - working at desk: starWorking
// - other: star
let anchorX = star.x;
let anchorY = star.y;
if (currentState === 'syncing' && syncAnimSprite && syncAnimSprite.visible) {
anchorX = syncAnimSprite.x;
anchorY = syncAnimSprite.y;
} else if (currentState === 'error' && window.errorBug && window.errorBug.visible) {
anchorX = window.errorBug.x;
anchorY = window.errorBug.y;
} else if (!star.visible && window.starWorking && window.starWorking.visible) {
anchorX = window.starWorking.x;
anchorY = window.starWorking.y;
}
const text = texts[Math.floor(Math.random() * texts.length)];
const bubbleY = anchorY - 70;
// 只做手机端稍微调大一点,避免发糊
const isTouch = IS_TOUCH_DEVICE;
const fontSize = isTouch ? 14 : 12;
const bg = game.add.rectangle(anchorX, bubbleY, text.length * 10 + 20, 28, 0xffffff, 0.95);
bg.setStrokeStyle(2, 0x000000);
const txt = game.add.text(anchorX, bubbleY, text, { fontFamily: 'ArkPixel, monospace', fontSize: fontSize + 'px', fill: '#000', align: 'center' }).setOrigin(0.5);
bubble = game.add.container(0, 0, [bg, txt]);
bubble.setDepth(1200); // always above desk/star
setTimeout(() => { if (bubble) { bubble.destroy(); bubble = null; } }, 3000);
}
function showCatBubble() {
if (!window.catSprite) return;
if (window.catBubble) { window.catBubble.destroy(); window.catBubble = null; }
const texts = BUBBLE_TEXTS.cat || ['喵~', '咕噜咕噜…'];
const text = texts[Math.floor(Math.random() * texts.length)];
const anchorX = window.catSprite.x;
const anchorY = window.catSprite.y - 60;
const bg = game.add.rectangle(anchorX, anchorY, text.length * 10 + 20, 24, 0xfffbeb, 0.95);
bg.setStrokeStyle(2, 0xd4a574);
const txt = game.add.text(anchorX, anchorY, text, { fontFamily: 'ArkPixel, monospace', fontSize: '11px', fill: '#8b6914', align: 'center' }).setOrigin(0.5);
window.catBubble = game.add.container(0, 0, [bg, txt]);
window.catBubble.setDepth(2100); // top layer above cat
setTimeout(() => { if (window.catBubble) { window.catBubble.destroy(); window.catBubble = null; } }, 4000);
}
// 假 Agent 气泡逻辑已移除,统一以真实 /agents 数据为准
// 启动游戏
initGame();
</script>
</body>
</html>