feat(electron): add standalone desktop UI with editable office plaque

Rebuild the Electron standalone interface with synchronized i18n/state behavior, improved mini-window sprite rendering, and DOM-based office plaque editing for better readability and customization.

Made-with: Cursor
This commit is contained in:
Zhaohan Wang 2026-03-04 15:00:30 +08:00
parent 9f8c909c9e
commit 21387da895
8 changed files with 6932 additions and 174 deletions

1
.gitignore vendored
View file

@ -28,3 +28,4 @@ frontend/office_bg.png
runtime-config.json
assets/bg-history/
frontend/*.bak
electron-shell/node_modules/

View file

@ -23,6 +23,8 @@ except Exception:
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
MEMORY_DIR = os.path.join(os.path.dirname(ROOT_DIR), "memory")
FRONTEND_DIR = os.path.join(ROOT_DIR, "frontend")
FRONTEND_INDEX_FILE = os.path.join(FRONTEND_DIR, "index.html")
FRONTEND_ELECTRON_STANDALONE_FILE = os.path.join(FRONTEND_DIR, "electron-standalone.html")
STATE_FILE = os.path.join(ROOT_DIR, "state.json")
AGENTS_STATE_FILE = os.path.join(ROOT_DIR, "agents-state.json")
JOIN_KEYS_FILE = os.path.join(ROOT_DIR, "join-keys.json")
@ -264,15 +266,47 @@ def save_state(state: dict):
json.dump(state, f, ensure_ascii=False, indent=2)
def ensure_electron_standalone_snapshot():
"""Create Electron standalone frontend snapshot once if missing.
The snapshot is intentionally decoupled from the browser page:
- browser uses frontend/index.html
- Electron uses frontend/electron-standalone.html
"""
if os.path.exists(FRONTEND_ELECTRON_STANDALONE_FILE):
return
try:
shutil.copy2(FRONTEND_INDEX_FILE, FRONTEND_ELECTRON_STANDALONE_FILE)
print(f"[standalone] created: {FRONTEND_ELECTRON_STANDALONE_FILE}")
except Exception as e:
print(f"[standalone] create failed: {e}")
# Initialize state
if not os.path.exists(STATE_FILE):
save_state(DEFAULT_STATE)
ensure_electron_standalone_snapshot()
@app.route("/", methods=["GET"])
def index():
"""Serve the pixel office UI with built-in version cache busting"""
with open(os.path.join(FRONTEND_DIR, "index.html"), "r", encoding="utf-8") as f:
with open(FRONTEND_INDEX_FILE, "r", encoding="utf-8") as f:
html = f.read()
html = html.replace("{{VERSION_TIMESTAMP}}", VERSION_TIMESTAMP)
resp = make_response(html)
resp.headers["Content-Type"] = "text/html; charset=utf-8"
return resp
@app.route("/electron-standalone", methods=["GET"])
def electron_standalone_page():
"""Serve Electron-only standalone frontend page."""
ensure_electron_standalone_snapshot()
target = FRONTEND_ELECTRON_STANDALONE_FILE
if not os.path.exists(target):
target = FRONTEND_INDEX_FILE
with open(target, "r", encoding="utf-8") as f:
html = f.read()
html = html.replace("{{VERSION_TIMESTAMP}}", VERSION_TIMESTAMP)
resp = make_response(html)

View file

@ -6,10 +6,7 @@
<title>Star Mini</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
/* 调这个值可控制“状态栏”和“精灵”之间的距离(越大越远) */
--mini-status-sprite-gap: 0px;
}
:root { --mini-status-sprite-gap: 0px; }
html, body {
width: 100%;
height: 100%;
@ -25,7 +22,7 @@
-webkit-app-region: drag;
}
body.electron-shell #status-pill,
body.electron-shell #pet {
body.electron-shell #pet-canvas {
-webkit-app-region: no-drag;
}
#wrap {
@ -62,11 +59,10 @@
cursor: pointer;
transition: transform 0.14s ease, filter 0.18s ease;
}
#pet {
#pet-canvas {
width: 140px;
height: 140px;
image-rendering: pixelated;
object-fit: contain;
pointer-events: none;
transform: scale(1);
filter: drop-shadow(0 0 0 rgba(250, 244, 207, 0));
@ -76,23 +72,21 @@
transform: translateY(-2px);
filter: brightness(1.04);
}
#pet-box:hover #pet {
#pet-box:hover #pet-canvas {
filter: drop-shadow(0 0 8px rgba(250, 244, 207, 0.2));
}
#pet-box:active {
transform: translateY(0);
filter: brightness(0.98);
}
#hint {
display: none;
}
#hint { display: none; }
</style>
</head>
<body>
<div id="wrap">
<div id="status-pill">加载中...</div>
<div id="pet-box" title="点击恢复主窗口">
<img id="pet" src="http://127.0.0.1:18791/static/star-idle.gif" alt="Star" />
<canvas id="pet-canvas" width="140" height="140" aria-label="Star"></canvas>
</div>
<div id="hint">点击形象恢复主窗口</div>
</div>
@ -104,25 +98,51 @@
const eventApi = isTauri ? window.__TAURI__.event : null;
const win = isTauri ? window.__TAURI__.window.getCurrentWindow() : null;
const status = document.getElementById('status-pill');
const box = document.getElementById('pet-box');
const pet = document.getElementById('pet');
if (isElectron) {
document.body.classList.add('electron-shell');
const petCanvas = document.getElementById('pet-canvas');
const petCtx = petCanvas && petCanvas.getContext ? petCanvas.getContext('2d') : null;
if (isElectron) document.body.classList.add('electron-shell');
const BASE_URL = 'http://127.0.0.1:18791';
const STATIC_URL = `${BASE_URL}/static/`;
let uiLang = 'en';
const I18N = {
zh: { stateIdle: '待命', stateWriting: '整理文档', stateResearching: '搜索信息', stateExecuting: '执行任务', stateSyncing: '同步备份', stateError: '出错了', fallbackIdleDetail: '待命', connecting: '连接中...' },
en: { stateIdle: 'Standby', stateWriting: 'Organizing Docs', stateResearching: 'Researching', stateExecuting: 'Executing Tasks', stateSyncing: 'Syncing Backup', stateError: 'Error', fallbackIdleDetail: 'Standby', connecting: 'Connecting...' },
ja: { stateIdle: '待機', stateWriting: '文書整理', stateResearching: '情報検索', stateExecuting: 'タスク実行', stateSyncing: '同期バックアップ', stateError: 'エラー発生', fallbackIdleDetail: '待機', connecting: '接続中...' }
};
const t = (key) => ((I18N[uiLang] && I18N[uiLang][key]) || key);
// Keep exactly aligned with main page asset names.
const PET_ASSET_PATHS = {
idle: 'star-idle-v5.png',
working: 'star-working-spritesheet-grid.webp',
syncing: 'sync-animation-v3-grid.webp',
error: 'error-bug-spritesheet-grid.webp'
};
const PET_FRAME_CONFIG = {
idle: { frameW: 256, frameH: 256, fps: 12 },
working: { frameW: 300, frameH: 300, fps: 12 },
syncing: { frameW: 256, frameH: 256, fps: 12 },
error: { frameW: 220, frameH: 220, fps: 12 }
};
// Align perceived sprite size with main page defaults.
const PET_SCALE = { idle: 1.2, working: 1.4, syncing: 1.2, error: 1.2 };
let assetRefreshTick = Date.now();
let lastAssetKey = null;
let currentSpriteSrc = '';
let spriteImage = null;
let spriteMeta = { frameW: 1, frameH: 1, fps: 1, start: 0, end: 0, frames: 1 };
let currentFrame = 0;
let lastFrameAt = 0;
let rafId = null;
window.__miniLastState = { state: 'idle' };
function normalizeLang(rawLang) {
const v = String(rawLang || '').toLowerCase();
if (v === 'zh' || v === 'en' || v === 'ja') return v;
return 'en';
}
const PET_ASSETS = {
idle: 'http://127.0.0.1:18791/static/star-idle.gif',
working: 'http://127.0.0.1:18791/static/star-working.gif',
syncing: 'http://127.0.0.1:18791/static/sync-animation.webp',
error: 'http://127.0.0.1:18791/static/error-bug.webp'
};
const PET_SCALE = {
idle: 1,
working: 1.6,
syncing: 1.2,
error: 0.9
};
function normalizeState(state) {
if (!state) return 'idle';
if (state === 'working' || state === 'writing' || state === 'researching' || state === 'executing') return 'working';
@ -130,61 +150,154 @@
if (state === 'error') return 'error';
return 'idle';
}
function stateLabel(rawState) {
const s = normalizeState(rawState);
if (s === 'working') return '工作';
if (s === 'syncing') return '同步';
if (s === 'error') return '报错';
return '休息';
const s = String(rawState || '').toLowerCase();
if (s === 'writing' || s === 'working') return t('stateWriting');
if (s === 'researching') return t('stateResearching');
if (s === 'executing' || s === 'run' || s === 'running') return t('stateExecuting');
if (s === 'syncing' || s === 'sync') return t('stateSyncing');
if (s === 'error') return t('stateError');
return t('stateIdle');
}
function buildPetSrcByState(rawState) {
const key = normalizeState(rawState);
const rel = PET_ASSET_PATHS[key] || PET_ASSET_PATHS.idle;
return { key, src: `${STATIC_URL}${rel}?v=${assetRefreshTick}` };
}
function resolveFrameRangeByState(stateKey, totalFrames) {
const maxIdx = Math.max(0, totalFrames - 1);
if (stateKey === 'working') {
return { start: 0, end: Math.min(37, maxIdx) };
}
if (stateKey === 'error') {
return { start: 0, end: Math.min(71, maxIdx) };
}
if (stateKey === 'syncing') {
if (totalFrames >= 3) {
const start = 1;
const end = Math.max(start, totalFrames - 2);
return { start, end };
}
return { start: 0, end: 0 };
}
// idle: use full available frames
return { start: 0, end: maxIdx };
}
function drawFrame() {
if (!petCtx || !spriteImage || !spriteMeta.frames) return;
const cols = Math.max(1, Math.floor(spriteImage.naturalWidth / spriteMeta.frameW));
const frame = spriteMeta.start + (currentFrame % spriteMeta.frames);
const sx = (frame % cols) * spriteMeta.frameW;
const sy = Math.floor(frame / cols) * spriteMeta.frameH;
petCtx.clearRect(0, 0, petCanvas.width, petCanvas.height);
petCtx.imageSmoothingEnabled = false;
petCtx.drawImage(
spriteImage,
sx, sy, spriteMeta.frameW, spriteMeta.frameH,
0, 0, petCanvas.width, petCanvas.height
);
}
function stopSpriteLoop() {
if (rafId) cancelAnimationFrame(rafId);
rafId = null;
}
function startSpriteLoop() {
stopSpriteLoop();
const tick = (ts) => {
if (!spriteImage || !spriteMeta.frames) return;
const interval = 1000 / Math.max(1, spriteMeta.fps || 1);
if (!lastFrameAt || ts - lastFrameAt >= interval) {
currentFrame = (currentFrame + 1) % Math.max(1, spriteMeta.frames);
drawFrame();
lastFrameAt = ts;
}
rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
}
function loadSpriteByState(rawState) {
const next = buildPetSrcByState(rawState);
const cfg = PET_FRAME_CONFIG[next.key] || PET_FRAME_CONFIG.idle;
if (currentSpriteSrc === next.src && lastAssetKey === next.key) return;
const img = new Image();
img.onload = () => {
spriteImage = img;
const cols = Math.max(1, Math.floor(img.naturalWidth / cfg.frameW));
const rows = Math.max(1, Math.floor(img.naturalHeight / cfg.frameH));
const totalFrames = Math.max(1, cols * rows);
const range = resolveFrameRangeByState(next.key, totalFrames);
const frames = Math.max(1, range.end - range.start + 1);
spriteMeta = {
frameW: cfg.frameW,
frameH: cfg.frameH,
fps: cfg.fps,
start: range.start,
end: range.end,
frames
};
currentFrame = 0;
lastFrameAt = 0;
drawFrame();
if (frames > 1) startSpriteLoop();
else stopSpriteLoop();
currentSpriteSrc = next.src;
lastAssetKey = next.key;
};
img.onerror = () => {
if (next.key !== 'idle') {
currentSpriteSrc = '';
lastAssetKey = null;
loadSpriteByState('idle');
}
};
img.src = next.src;
}
function applyState(data, instant = false) {
const state = data && data.state ? data.state : 'idle';
const detail = data && data.detail ? data.detail : '待命中';
const payload = data || { state: 'idle' };
uiLang = normalizeLang(payload.ui_lang || uiLang);
window.__miniLastState = payload;
const state = payload.state || 'idle';
const detail = payload.detail || t('fallbackIdleDetail');
status.textContent = `[${stateLabel(state)}] ${detail}`;
loadSpriteByState(state);
const assetKey = normalizeState(state);
const nextSrc = PET_ASSETS[assetKey] || PET_ASSETS.idle;
if (pet.getAttribute('src') !== nextSrc) {
pet.setAttribute('src', nextSrc);
}
const scale = PET_SCALE[normalizeState(state)] || 1;
if (instant) {
const prevTransition = pet.style.transition;
pet.style.transition = 'none';
pet.style.transform = `scale(${PET_SCALE[assetKey] || 1})`;
const prevTransition = petCanvas.style.transition;
petCanvas.style.transition = 'none';
petCanvas.style.transform = `scale(${scale})`;
requestAnimationFrame(() => {
pet.style.transition = prevTransition || 'transform 0.16s ease, filter 0.2s ease';
petCanvas.style.transition = prevTransition || 'transform 0.16s ease, filter 0.2s ease';
});
return;
} else {
petCanvas.style.transform = `scale(${scale})`;
}
pet.style.transform = `scale(${PET_SCALE[assetKey] || 1})`;
}
async function fetchStatus() {
if (!core) return;
try {
const data = await core.invoke('read_state');
if (core) {
const data = await core.invoke('read_state');
applyState(data);
return;
}
const resp = await fetch(`${BASE_URL}/status`, { cache: 'no-store' });
if (!resp.ok) throw new Error('bad status');
const data = await resp.json();
applyState(data);
} catch (_) {
// Fallback for packaged app: read from backend HTTP when invoke path is unavailable.
try {
const resp = await fetch('http://127.0.0.1:18791/status', { cache: 'no-store' });
if (!resp.ok) throw new Error('bad status');
const data = await resp.json();
applyState(data);
} catch (_) {
status.textContent = '连接中...';
}
status.textContent = t('connecting');
}
}
if (eventApi && eventApi.listen) {
try {
await eventApi.listen('mini-sync-state', (evt) => {
if (evt && evt.payload) {
applyState(evt.payload, true);
}
if (evt && evt.payload) applyState(evt.payload, true);
});
} catch (_) {}
}
@ -192,40 +305,31 @@
let downAt = null;
let dragTriggered = false;
const DRAG_THRESHOLD = 6;
document.addEventListener('pointerdown', (e) => {
if (e.button !== 0) return;
downAt = { x: e.clientX, y: e.clientY };
dragTriggered = false;
});
document.addEventListener('pointermove', async (e) => {
if (!downAt || dragTriggered || !win) return;
const moved = Math.hypot(e.clientX - downAt.x, e.clientY - downAt.y);
if (moved < DRAG_THRESHOLD) return;
dragTriggered = true;
try {
await win.startDragging();
} catch (_) {}
try { await win.startDragging(); } catch (_) {}
});
document.addEventListener('pointerup', async () => {
if (!downAt) return;
const wasDrag = dragTriggered;
downAt = null;
dragTriggered = false;
// Click (not drag) to restore main window.
if (!wasDrag && core) {
try { await core.invoke('restore_main_window'); } catch (_) {}
}
});
document.addEventListener('contextmenu', async (e) => {
e.preventDefault();
if (!core) return;
try {
await core.invoke('close_app');
} catch (_) {}
try { await core.invoke('close_app'); } catch (_) {}
});
await fetchStatus();

View file

@ -10,6 +10,7 @@ let miniWindow = null;
let tray = null;
let backendChild = null;
let isQuitting = false;
let currentUiLang = "en";
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
@ -171,6 +172,19 @@ function spawnBackend(projectRoot) {
return null;
}
function ensureElectronStandaloneSnapshot(projectRoot) {
const src = path.join(projectRoot, "frontend", "index.html");
const dst = path.join(projectRoot, "frontend", "electron-standalone.html");
if (!fs.existsSync(src)) return;
if (fs.existsSync(dst)) return;
try {
fs.copyFileSync(src, dst);
console.log(`created standalone snapshot: ${dst}`);
} catch (e) {
console.warn(`failed to create standalone snapshot: ${e.message}`);
}
}
function emitMini(event, payload) {
if (!miniWindow || miniWindow.isDestroyed()) return;
miniWindow.webContents.send("tauri:event", { event, payload });
@ -178,7 +192,7 @@ function emitMini(event, payload) {
async function enterMiniMode(projectRoot) {
const snapshot = await readStateWithFallback(projectRoot).catch(() => null);
if (snapshot) emitMini("mini-sync-state", snapshot);
if (snapshot) emitMini("mini-sync-state", { ...snapshot, ui_lang: currentUiLang });
if (mainWindow && !mainWindow.isDestroyed()) {
const bounds = mainWindow.getBounds();
@ -201,6 +215,7 @@ async function openFrontendAndQuit() {
function createWindows(projectRoot) {
const preloadPath = path.join(__dirname, "preload.js");
const appIconPath = resolveAppIconPath(projectRoot);
ensureElectronStandaloneSnapshot(projectRoot);
mainWindow = new BrowserWindow({
width: 700,
@ -242,7 +257,7 @@ function createWindows(projectRoot) {
});
miniWindow.setTitle("Star Mini");
const mainUrl = "http://127.0.0.1:18791/?desktop=1";
const mainUrl = "http://127.0.0.1:18791/static/electron-standalone.html?desktop=1";
mainWindow.loadURL(mainUrl);
miniWindow.loadFile(path.join(projectRoot, "desktop-pet", "src", "minimized.html"));
}
@ -309,7 +324,18 @@ function registerIpc(projectRoot) {
const cmd = payload && payload.command;
const args = (payload && payload.args) || {};
if (cmd === "read_state") return readStateWithFallback(projectRoot);
if (cmd === "read_state") {
const state = await readStateWithFallback(projectRoot);
return { ...state, ui_lang: currentUiLang };
}
if (cmd === "set_ui_lang") {
const lang = String(args && args.lang ? args.lang : "").toLowerCase();
if (lang === "zh" || lang === "en" || lang === "ja") {
currentUiLang = lang;
}
return { ok: true, lang: currentUiLang };
}
if (cmd === "enter_minimize_mode") {
await enterMiniMode(projectRoot);

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,133 @@
// Star Office UI - 布局与层级配置
// 所有坐标、depth、资源路径统一管理在这里
// 避免 magic numbers降低改错风险
// 核心规则:
// - 透明资源(如办公桌)强制 .png不透明优先 .webp
// - 层级:低 → sofa(10) → starWorking(900) → desk(1000) → flower(1100)
const LAYOUT = {
// === 游戏画布 ===
game: {
width: 1280,
height: 720
},
// === 各区域坐标 ===
areas: {
door: { x: 640, y: 550 },
writing: { x: 320, y: 360 },
researching: { x: 320, y: 360 },
error: { x: 1066, y: 180 },
breakroom: { x: 640, y: 360 }
},
// === 装饰与家具:坐标 + 原点 + depth ===
furniture: {
// 沙发
sofa: {
x: 670,
y: 144,
origin: { x: 0, y: 0 },
depth: 10
},
// 新办公桌(透明 PNG 强制)
desk: {
x: 218,
y: 417,
origin: { x: 0.5, y: 0.5 },
depth: 1000
},
// 桌上花盆
flower: {
x: 310,
y: 390,
origin: { x: 0.5, y: 0.5 },
depth: 1100,
scale: 0.8
},
// Star 在桌前工作(在 desk 下面)
starWorking: {
x: 217,
y: 333,
origin: { x: 0.5, y: 0.5 },
depth: 900,
scale: 1.32
},
// 植物们
plants: [
{ x: 565, y: 178, depth: 5 },
{ x: 230, y: 185, depth: 5 },
{ x: 977, y: 496, depth: 5 }
],
// 海报
poster: {
x: 252,
y: 66,
depth: 4
},
// 咖啡机
coffeeMachine: {
x: 659,
y: 397,
origin: { x: 0.5, y: 0.5 },
depth: 99
},
// 服务器区
serverroom: {
x: 1021,
y: 142,
origin: { x: 0.5, y: 0.5 },
depth: 2
},
// 错误 bug
errorBug: {
x: 1007,
y: 221,
origin: { x: 0.5, y: 0.5 },
depth: 50,
scale: 0.9,
pingPong: { leftX: 1007, rightX: 1111, speed: 0.6 }
},
// 同步动画
syncAnim: {
x: 1157,
y: 592,
origin: { x: 0.5, y: 0.5 },
depth: 40
},
// 小猫
cat: {
x: 94,
y: 557,
origin: { x: 0.5, y: 0.5 },
depth: 2000
}
},
// === 牌匾 ===
plaque: {
x: 640,
y: 720 - 36,
width: 420,
height: 44
},
// === 资源加载规则:哪些强制用 PNG透明资源 ===
forcePng: {
desk_v2: true // 新办公桌必须透明,强制 PNG
},
// === 总资源数量(用于加载进度条) ===
totalAssets: 15
};

File diff suppressed because it is too large Load diff

View file

@ -1009,9 +1009,9 @@
<div id="game-container">
<div id="status-text">加载中...</div>
</div>
<!-- 底部面板容器 -->
<div id="bottom-panels">
<!-- 底部面板容器 -->
<div id="bottom-panels">
<!-- Memo 面板 -->
<div id="memo-panel">
<div id="memo-title">昨 日 小 记</div>
@ -1041,9 +1041,9 @@
<div id="guest-agent-list">
<div style="color:#9ca3af;font-size:12px;text-align:center;padding:20px 0;">正在加载访客...</div>
</div>
</div>
</div>
</div>
</div>
<div id="asset-highlight"></div>
<div id="room-loading-overlay" aria-live="polite" aria-busy="true">
@ -3514,12 +3514,12 @@ function toggleBrokerPanel() {
const states = ['idle', 'writing', 'researching', 'executing', 'syncing', 'error'];
const bubbleTextMapByLang = {
zh: {
idle: '我去休息区躺一下',
writing: '我在工作中',
researching: '我在调研中',
executing: '我在执行任务',
syncing: '我在同步状态',
error: '出错了!我去报警区'
idle: '我去休息区躺一下',
writing: '我在工作中',
researching: '我在调研中',
executing: '我在执行任务',
syncing: '我在同步状态',
error: '出错了!我去报警区'
},
en: {
idle: 'Taking a break in the lounge.',
@ -3711,7 +3711,7 @@ function toggleBrokerPanel() {
} catch (e) {
console.warn('flowers 规格探测失败,使用默认 65x65', e);
}
// 启动 Phaser 游戏
new Phaser.Game(config);
setTimeout(() => { applySavedPositionOverrides(); }, 600);
@ -4022,12 +4022,12 @@ function toggleBrokerPanel() {
this.anims.remove('sync_anim');
}
if (syncAnimPlayable) {
this.anims.create({
key: 'sync_anim',
this.anims.create({
key: 'sync_anim',
frames: this.anims.generateFrameNumbers('sync_anim', { start: syncFrameStart, end: syncFrameEnd }),
frameRate: 12,
repeat: -1
});
frameRate: 12,
repeat: -1
});
}
syncAnimSprite = this.add.sprite(1157, 592, 'sync_anim', 0).setOrigin(0.5);
syncAnimSprite.setDepth(40);
@ -4279,10 +4279,10 @@ function toggleBrokerPanel() {
if (syncAnimSprite) {
if (effectiveStateForServer === 'syncing') {
if (syncAnimPlayable && syncAnimSprite.anims && syncAnimSprite.anims.play && syncAnimSprite.scene?.anims?.exists('sync_anim')) {
if (!syncAnimSprite.anims.isPlaying || syncAnimSprite.anims.currentAnim?.key !== 'sync_anim') {
syncAnimSprite.anims.play('sync_anim', true);
}
} else {
if (!syncAnimSprite.anims.isPlaying || syncAnimSprite.anims.currentAnim?.key !== 'sync_anim') {
syncAnimSprite.anims.play('sync_anim', true);
}
} else {
syncAnimSprite.setFrame(0);
}
} else {
@ -4352,101 +4352,101 @@ function toggleBrokerPanel() {
.then(response => response.json())
.then(data => {
try {
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;
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;
// Set state immediately (no waypoints/path movement)
pendingDesiredState = null;
currentState = nextState;
// Idle: show Star idle animation (main character)
if (nextState === 'idle') {
if (nextState === 'idle') {
sofa.anims.stop();
sofa.setTexture('sofa_idle');
if (window.starWorking) {
window.starWorking.setVisible(false);
window.starWorking.anims.stop();
}
if (window.starWorking) {
window.starWorking.setVisible(false);
window.starWorking.anims.stop();
}
star.setVisible(true);
star.setScale(IDLE_STAR_SCALE);
star.anims.play('star_idle', true);
star.setPosition(IDLE_SOFA_ANCHOR.x, IDLE_SOFA_ANCHOR.y);
} 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 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 (syncAnimPlayable && syncAnimSprite.anims && syncAnimSprite.anims.play && syncAnimSprite.scene?.anims?.exists('sync_anim')) {
if (!syncAnimSprite.anims.isPlaying || syncAnimSprite.anims.currentAnim?.key !== 'sync_anim') {
syncAnimSprite.anims.play('sync_anim', true);
}
} 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 (syncAnimPlayable && syncAnimSprite.anims && syncAnimSprite.anims.play && syncAnimSprite.scene?.anims?.exists('sync_anim')) {
if (!syncAnimSprite.anims.isPlaying || syncAnimSprite.anims.currentAnim?.key !== 'sync_anim') {
syncAnimSprite.anims.play('sync_anim', true);
}
} else {
syncAnimSprite.setFrame(0);
}
} else {
if (syncAnimSprite.anims && syncAnimSprite.anims.isPlaying) syncAnimSprite.anims.stop();
syncAnimSprite.setFrame(0);
}
}
} else {
if (!typewriterTarget || typewriterTarget !== nextLine) {
typewriterTarget = nextLine;
typewriterText = '';
typewriterIndex = 0;
syncAnimSprite.setFrame(0);
}
}
} else {
if (!typewriterTarget || typewriterTarget !== nextLine) {
typewriterTarget = nextLine;
typewriterText = '';
typewriterIndex = 0;
}
}
} catch (err) {
console.error('fetchStatus apply error', err);
typewriterTarget = '状态更新异常,正在恢复...';