mirror of
https://github.com/ringhyacinth/Star-Office-UI
synced 2026-04-21 13:27:19 +00:00
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:
parent
9f8c909c9e
commit
21387da895
8 changed files with 6932 additions and 174 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -28,3 +28,4 @@ frontend/office_bg.png
|
|||
runtime-config.json
|
||||
assets/bg-history/
|
||||
frontend/*.bak
|
||||
electron-shell/node_modules/
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
1001
electron-shell/standalone-assets/game.js
Normal file
1001
electron-shell/standalone-assets/game.js
Normal file
File diff suppressed because it is too large
Load diff
133
electron-shell/standalone-assets/layout.js
Normal file
133
electron-shell/standalone-assets/layout.js
Normal 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
|
||||
};
|
||||
5459
frontend/electron-standalone.html
Normal file
5459
frontend/electron-standalone.html
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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 = '状态更新异常,正在恢复...';
|
||||
|
|
|
|||
Loading…
Reference in a new issue