mirror of
https://github.com/ringhyacinth/Star-Office-UI
synced 2026-04-21 13:27:19 +00:00
4640 lines
232 KiB
HTML
4640 lines
232 KiB
HTML
<!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: 10px;
|
||
overflow-x: hidden;
|
||
}
|
||
/* 底部面板容器 */
|
||
#main-stage {
|
||
position: relative;
|
||
width: 1280px;
|
||
transition: margin-left .25s ease;
|
||
will-change: margin-left;
|
||
}
|
||
body.drawer-open #main-stage {
|
||
/* 右侧紧贴侧边栏左缘,避免出现大空隙 */
|
||
margin-left: max(0px, calc(100vw - min(420px, 92vw) - 1280px));
|
||
}
|
||
#bottom-panels {
|
||
display: flex;
|
||
gap: 20px;
|
||
width: 1280px;
|
||
max-width: none;
|
||
justify-content: flex-start;
|
||
margin-top: 20px;
|
||
}
|
||
#game-container {
|
||
position: relative;
|
||
border: 0;
|
||
image-rendering: pixelated;
|
||
width: 1280px;
|
||
height: 720px;
|
||
max-width: none;
|
||
max-height: none;
|
||
aspect-ratio: auto;
|
||
overflow: hidden;
|
||
}
|
||
#game-container canvas {
|
||
width: 100% !important;
|
||
height: 100% !important;
|
||
image-rendering: pixelated;
|
||
/* 再兜底一次:即使外层高度变化,也不要拉伸变形 */
|
||
object-fit: contain;
|
||
/* 边框改为直接贴合画布内部,避免“框比地图大” */
|
||
box-shadow: inset 0 0 0 4px #64477d;
|
||
position: relative;
|
||
z-index: 10;
|
||
}
|
||
#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: absolute;
|
||
bottom: 12px;
|
||
left: 12px;
|
||
transform: none;
|
||
color: #eee;
|
||
font-size: 14px;
|
||
background: rgba(0,0,0,0.7);
|
||
padding: 8px 12px;
|
||
border-radius: 4px;
|
||
max-width: calc(100% - 24px);
|
||
text-align: left;
|
||
font-family: 'ArkPixel', 'Courier New', monospace;
|
||
z-index: 30;
|
||
pointer-events: none;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
line-height: 1.2;
|
||
}
|
||
/* 状态控制栏 */
|
||
#control-bar {
|
||
position: relative;
|
||
background: #141722;
|
||
padding: 10px 10px 12px;
|
||
border-radius: 0;
|
||
border: 4px solid #0e1119;
|
||
box-shadow: none;
|
||
width: 390px;
|
||
height: 300px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
overflow: hidden;
|
||
}
|
||
#control-bar::before {
|
||
content: '';
|
||
position: absolute;
|
||
inset: 0;
|
||
pointer-events: none;
|
||
background-image:
|
||
linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc),
|
||
linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc),
|
||
linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc),
|
||
linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc);
|
||
background-repeat: no-repeat;
|
||
background-size:
|
||
calc(50% - 14px) 2px, calc(50% - 14px) 2px,
|
||
2px calc(50% - 14px), 2px calc(50% - 14px),
|
||
calc(50% - 14px) 2px, calc(50% - 14px) 2px,
|
||
2px calc(50% - 14px), 2px calc(50% - 14px);
|
||
background-position:
|
||
9px 8px, calc(50% + 5px) 8px,
|
||
8px 9px, calc(100% - 10px) 9px,
|
||
9px calc(100% - 10px), calc(50% + 5px) calc(100% - 10px),
|
||
8px calc(50% + 5px), calc(100% - 10px) calc(50% + 5px);
|
||
}
|
||
#control-bar::after {
|
||
content: '';
|
||
position: absolute;
|
||
inset: 0;
|
||
pointer-events: none;
|
||
background-image:
|
||
linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc),
|
||
linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc),
|
||
linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc),
|
||
linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc);
|
||
background-repeat: no-repeat;
|
||
background-size:
|
||
9px 4px, 4px 9px,
|
||
9px 4px, 4px 9px,
|
||
9px 4px, 4px 9px,
|
||
9px 4px, 4px 9px;
|
||
background-position:
|
||
left top, left top,
|
||
right top, right top,
|
||
left bottom, left bottom,
|
||
right bottom, right bottom;
|
||
}
|
||
#control-bar-title {
|
||
color: #ffd700;
|
||
font-size: 16px;
|
||
font-weight: bold;
|
||
text-align: center;
|
||
letter-spacing: 1px;
|
||
padding: 6px 0 10px;
|
||
border-bottom: 0;
|
||
}
|
||
#control-buttons {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
gap: 8px;
|
||
align-content: start;
|
||
padding-top: 4px;
|
||
padding-left: 10px;
|
||
padding-right: 10px;
|
||
box-sizing: border-box;
|
||
}
|
||
#btn-open-drawer {
|
||
grid-column: 1 / -1;
|
||
background: #78a340;
|
||
border-color: #8fbe4a;
|
||
color: #f3ffe6;
|
||
font-weight: 700;
|
||
}
|
||
|
||
#asset-drawer {
|
||
position: fixed;
|
||
top: 0;
|
||
right: 0;
|
||
width: 320px;
|
||
max-width: 92vw;
|
||
height: 100vh;
|
||
background: #111827;
|
||
border-left: 2px solid #22c55e;
|
||
box-shadow: -8px 0 24px rgba(0,0,0,0.45);
|
||
transform: translateX(100%);
|
||
transition: transform 0.25s ease;
|
||
z-index: 1000010;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
#asset-drawer.open { transform: translateX(0); }
|
||
#asset-drawer-header {
|
||
color: #ecfdf5;
|
||
font-size: 15px;
|
||
padding: 12px;
|
||
border-bottom: 1px solid #374151;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
background: #0b1220;
|
||
}
|
||
#asset-drawer-body {
|
||
padding: 10px;
|
||
padding-bottom: 150px;
|
||
overflow: auto;
|
||
color: #e5e7eb;
|
||
font-size: 12px;
|
||
position: relative;
|
||
}
|
||
.asset-toolbar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:10px; }
|
||
.asset-toolbar input { flex:1; min-width: 150px; padding:6px 8px; border-radius:6px; border:1px solid #374151; background:#1f2937; color:#fff; }
|
||
.asset-toolbar button, #asset-drawer-header button { cursor:pointer; border:1px solid #4b5563; background:#1f2937; color:#fff; border-radius:6px; padding:6px 8px; font-family:'ArkPixel', monospace; }
|
||
.asset-toolbar button:hover, #asset-drawer-header button:hover { border-color:#22c55e; }
|
||
#asset-list {
|
||
display:flex;
|
||
flex-direction:column;
|
||
gap:6px;
|
||
flex: 1 1 auto;
|
||
min-height: 120px;
|
||
max-height: 40vh;
|
||
overflow-y: auto;
|
||
padding-right: 2px;
|
||
scrollbar-color: #1f2937 #0b1220;
|
||
scrollbar-width: thin;
|
||
}
|
||
#asset-list::-webkit-scrollbar { width: 8px; }
|
||
#asset-list::-webkit-scrollbar-track { background: #0b1220; }
|
||
#asset-list::-webkit-scrollbar-thumb { background: #1f2937; border-radius: 0; border: 1px solid #111827; }
|
||
#asset-upload-panel {
|
||
position: sticky;
|
||
bottom: 8px;
|
||
margin-top: 10px;
|
||
background: #0b1220;
|
||
border: 1px solid #334155;
|
||
border-radius: 8px;
|
||
padding: 8px;
|
||
z-index: 50;
|
||
display: none;
|
||
box-shadow: 0 -4px 12px rgba(0,0,0,.35);
|
||
}
|
||
#asset-upload-panel.active {
|
||
display: block;
|
||
}
|
||
.asset-item {
|
||
border: 1px solid #374151;
|
||
background: #0f172a;
|
||
border-radius: 8px;
|
||
padding: 8px;
|
||
display: grid;
|
||
grid-template-columns: 56px 1fr 44px;
|
||
gap: 8px;
|
||
align-items: center;
|
||
cursor: pointer;
|
||
}
|
||
.asset-item.active { border-color: #22c55e; box-shadow: 0 0 0 1px #22c55e inset; }
|
||
.asset-vis-btn {
|
||
min-width: 34px;
|
||
height: 28px;
|
||
padding: 2px 4px;
|
||
border: 1px solid #4b5563;
|
||
background: #111827;
|
||
color: #d1d5db;
|
||
border-radius: 6px;
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
font-family:'ArkPixel', monospace;
|
||
}
|
||
.asset-vis-btn:hover { border-color:#22c55e; color:#ecfccb; }
|
||
.asset-thumb { width:56px; height:56px; object-fit: contain; background:#0b1220; border:1px solid #374151; border-radius:6px; }
|
||
.asset-meta { line-height: 1.45; }
|
||
.asset-path { color:#d1fae5; word-break: break-all; }
|
||
.asset-sub { color:#9ca3af; font-size:11px; }
|
||
#asset-upload-result { white-space: normal; line-height: 1.5; }
|
||
#asset-upload-result .hint-p { margin: 0 0 6px 0; }
|
||
#asset-upload-result .hint-p:last-child { margin-bottom: 0; }
|
||
.asset-plus-box { width:100%; height:92px; border:2px dashed #4b5563; border-radius:8px; display:flex; align-items:center; justify-content:center; color:#9ca3af; font-size:34px; cursor:pointer; user-select:none; }
|
||
.asset-plus-box:hover { border-color:#22c55e; color:#22c55e; }
|
||
.asset-preview-box { border:1px solid #374151; border-radius:8px; padding:6px; background:#0b1220; margin-bottom:8px; }
|
||
.asset-preview-title { color:#9ca3af; font-size:11px; margin-bottom:4px; }
|
||
.asset-preview-img { width:100%; height:92px; object-fit:contain; background:#111827; border:1px solid #1f2937; border-radius:6px; }
|
||
.home-fav-list { display:flex; gap:8px; overflow-x:auto; padding-bottom:4px; }
|
||
.home-fav-item { min-width:126px; max-width:126px; border:1px solid #334155; border-radius:8px; background:#111827; padding:6px; }
|
||
.home-fav-item img { width:100%; height:70px; object-fit:cover; border:1px solid #1f2937; border-radius:6px; image-rendering:pixelated; }
|
||
.home-fav-meta { color:#9ca3af; font-size:10px; margin-top:4px; line-height:1.3; min-height:24px; }
|
||
.home-fav-item button { width:100%; margin-top:4px; border:1px solid #4b5563; background:#1f2937; color:#fff; border-radius:6px; padding:4px 6px; font-family:'ArkPixel', monospace; cursor:pointer; }
|
||
.home-fav-item button:hover { border-color:#22c55e; }
|
||
#gemini-api-doc-link { color:#86efac; text-decoration: underline; text-underline-offset: 2px; }
|
||
#gemini-api-doc-link:hover { color:#bbf7d0; }
|
||
|
||
|
||
#asset-move-panel { border:1px solid #334155; background:#0b1220; border-radius:10px; padding:10px; margin-bottom:10px; }
|
||
#asset-home-actions-panel { border:1px solid #334155; background:#0b1220; border-radius:10px; padding:10px; }
|
||
#asset-home-actions-panel .asset-toolbar { display:grid; grid-template-columns: 1fr 1fr; gap:8px; }
|
||
#asset-home-actions-panel .asset-toolbar > button { width:100%; margin:0; }
|
||
#asset-move-row { justify-content: center; gap:12px; margin-bottom:0; }
|
||
#asset-move-row .btn-move,
|
||
#asset-move-row .btn-home,
|
||
#asset-broker-row .btn-broker,
|
||
#asset-broker-row .btn-diy {
|
||
width:122px;
|
||
height:42px;
|
||
padding:8px 8px 0;
|
||
border:none;
|
||
border-radius:0;
|
||
background-color: transparent !important;
|
||
background-repeat:no-repeat;
|
||
background-size:300% 100%;
|
||
background-position:0 0;
|
||
image-rendering: pixelated;
|
||
appearance:none;
|
||
-webkit-appearance:none;
|
||
color:#fff;
|
||
text-align:center;
|
||
font-size:14px;
|
||
font-weight:400;
|
||
letter-spacing:.2px;
|
||
text-shadow:none;
|
||
display:inline-flex;
|
||
align-items:flex-start;
|
||
justify-content:center;
|
||
transition: padding-top .08s ease, filter .12s ease;
|
||
box-shadow:none;
|
||
}
|
||
#asset-move-row .btn-move { color:#1f2937; }
|
||
#asset-move-row .btn-move {
|
||
background-image:url('/static/btn-move-house-sprite.png?v={{VERSION_TIMESTAMP}}');
|
||
}
|
||
#asset-move-row .btn-home {
|
||
background-image:url('/static/btn-back-home-sprite.png?v={{VERSION_TIMESTAMP}}');
|
||
}
|
||
#asset-broker-row { justify-content:center; gap:12px; margin-top:8px; margin-bottom:0; }
|
||
#asset-broker-row .btn-broker {
|
||
background-image:url('/static/btn-broker-sprite.png?v={{VERSION_TIMESTAMP}}');
|
||
}
|
||
#asset-broker-row .btn-diy {
|
||
background-image:url('/static/btn-diy-sprite.png?v={{VERSION_TIMESTAMP}}');
|
||
}
|
||
#asset-manual-panel {
|
||
margin-top:0;
|
||
max-height:0;
|
||
opacity:0;
|
||
transform:translateY(-6px);
|
||
overflow:hidden;
|
||
pointer-events:none;
|
||
transition:max-height .28s ease, opacity .22s ease, transform .28s ease, margin-top .28s ease;
|
||
}
|
||
#asset-manual-panel.open {
|
||
margin-top:8px;
|
||
max-height:1600px;
|
||
opacity:1;
|
||
transform:translateY(0);
|
||
pointer-events:auto;
|
||
}
|
||
#asset-broker-panel {
|
||
margin-top:0;
|
||
border:1px dashed #334155;
|
||
border-radius:8px;
|
||
padding:8px;
|
||
background:#0f172a;
|
||
max-height:0;
|
||
opacity:0;
|
||
transform:translateY(-6px);
|
||
overflow:hidden;
|
||
pointer-events:none;
|
||
transition:max-height .28s ease, opacity .22s ease, transform .28s ease, margin-top .28s ease;
|
||
}
|
||
#asset-broker-panel.open {
|
||
margin-top:8px;
|
||
max-height:520px;
|
||
opacity:1;
|
||
transform:translateY(0);
|
||
pointer-events:auto;
|
||
}
|
||
#asset-broker-prompt {
|
||
width:100%; min-height:66px; resize:vertical;
|
||
padding:8px; border-radius:6px;
|
||
border:1px solid #334155; background:#111827; color:#e5e7eb;
|
||
font-family:'ArkPixel', monospace; font-size:12px;
|
||
box-sizing:border-box;
|
||
}
|
||
#asset-broker-actions { margin-top:8px; display:flex; justify-content:flex-end; }
|
||
#asset-broker-actions button {
|
||
background:#0ea5e9;
|
||
color:#e0f2fe;
|
||
border-color:#38bdf8;
|
||
font-weight:700;
|
||
font-size:12px;
|
||
padding:7px 10px;
|
||
min-width:112px;
|
||
text-align:center;
|
||
box-shadow: 0 2px 0 rgba(0,0,0,.25);
|
||
transition: transform .08s ease, filter .12s ease, box-shadow .12s ease;
|
||
}
|
||
#asset-move-row .btn-move:hover,
|
||
#asset-move-row .btn-home:hover,
|
||
#asset-broker-row .btn-broker:hover,
|
||
#asset-broker-row .btn-diy:hover,
|
||
#asset-broker-actions button:hover {
|
||
filter: brightness(1.06);
|
||
background-color: transparent !important;
|
||
}
|
||
#asset-move-row .btn-move:active,
|
||
#asset-move-row .btn-home:active,
|
||
#asset-broker-row .btn-broker:active,
|
||
#asset-broker-row .btn-diy:active,
|
||
#asset-broker-actions button:active,
|
||
#asset-move-row .btn-move.is-active,
|
||
#asset-move-row .btn-home.is-active,
|
||
#asset-broker-row .btn-broker.is-active,
|
||
#asset-broker-row .btn-diy.is-active,
|
||
#asset-broker-actions button.is-active {
|
||
padding-top:13px;
|
||
filter: brightness(0.96);
|
||
background-color: transparent !important;
|
||
}
|
||
|
||
#asset-move-row .btn-move:active,
|
||
#asset-move-row .btn-move.is-active,
|
||
#asset-move-row .btn-home:active,
|
||
#asset-move-row .btn-home.is-active,
|
||
#asset-broker-row .btn-broker:active,
|
||
#asset-broker-row .btn-broker.is-active,
|
||
#asset-broker-row .btn-diy:active,
|
||
#asset-broker-row .btn-diy.is-active {
|
||
background-position:50% 0;
|
||
}
|
||
#asset-move-row .btn-move.is-done,
|
||
#asset-move-row .btn-home.is-done,
|
||
#asset-broker-row .btn-broker.is-done,
|
||
#asset-broker-row .btn-diy.is-done {
|
||
background-position:100% 0;
|
||
}
|
||
|
||
|
||
#asset-highlight {
|
||
position: fixed;
|
||
border: 3px solid #22c55e;
|
||
background: transparent;
|
||
box-shadow: none;
|
||
pointer-events: none;
|
||
display: none;
|
||
z-index: 999998;
|
||
}
|
||
#room-loading-overlay {
|
||
position: fixed;
|
||
left: 0;
|
||
top: 0;
|
||
width: 0;
|
||
height: 0;
|
||
background: rgba(0, 0, 0, 0.62);
|
||
z-index: 1000000;
|
||
display: none;
|
||
align-items: center;
|
||
justify-content: center;
|
||
pointer-events: auto;
|
||
border-radius: 10px;
|
||
}
|
||
.room-loading-inner {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 16px 20px;
|
||
border-radius: 10px;
|
||
border: 1px solid rgba(255,255,255,.2);
|
||
background: rgba(0,0,0,.36);
|
||
color: #fff;
|
||
font-family: 'ArkPixel', monospace;
|
||
font-size: 20px;
|
||
text-shadow: 0 2px 6px rgba(0,0,0,.45);
|
||
}
|
||
#room-loading-emoji {
|
||
font-size: 52px;
|
||
line-height: 1;
|
||
min-height: 56px;
|
||
}
|
||
#room-loading-text {
|
||
font-size: 20px;
|
||
letter-spacing: 1px;
|
||
}
|
||
#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;
|
||
}
|
||
/* Star 状态四按钮(不含装修)使用像素精灵皮肤 */
|
||
#control-bar #btn-state-idle,
|
||
#control-bar #btn-state-writing,
|
||
#control-bar #btn-state-syncing,
|
||
#control-bar #btn-state-error {
|
||
background-image: url('/static/btn-state-sprite.png?v={{VERSION_TIMESTAMP}}');
|
||
background-color: transparent !important;
|
||
background-repeat: no-repeat;
|
||
background-size: 300% 100%;
|
||
background-position: 0 0;
|
||
border: none;
|
||
border-radius: 0;
|
||
appearance: none;
|
||
-webkit-appearance: none;
|
||
image-rendering: pixelated;
|
||
color: #5e6366;
|
||
font-weight: 400;
|
||
text-shadow: none;
|
||
padding: 0 8px 9px;
|
||
line-height: 1;
|
||
transition: padding-top .08s ease, padding-bottom .08s ease, filter .12s ease;
|
||
}
|
||
#control-bar #btn-state-idle:hover,
|
||
#control-bar #btn-state-writing:hover,
|
||
#control-bar #btn-state-syncing:hover,
|
||
#control-bar #btn-state-error:hover {
|
||
background-color: transparent !important;
|
||
filter: brightness(1.04);
|
||
}
|
||
#control-bar #btn-state-idle:active,
|
||
#control-bar #btn-state-writing:active,
|
||
#control-bar #btn-state-syncing:active,
|
||
#control-bar #btn-state-error:active {
|
||
background-position: 50% 0;
|
||
padding-top: 5px;
|
||
padding-bottom: 0;
|
||
filter: brightness(0.97);
|
||
}
|
||
/* 装修房间按钮使用像素精灵皮肤 */
|
||
#control-bar #btn-open-drawer {
|
||
background-image: url('/static/btn-open-drawer-sprite.png?v={{VERSION_TIMESTAMP}}') !important;
|
||
background-color: transparent !important;
|
||
background-repeat: no-repeat !important;
|
||
background-size: 300% 100% !important;
|
||
background-position: 0 0 !important;
|
||
border: none !important;
|
||
border-radius: 0 !important;
|
||
appearance: none;
|
||
-webkit-appearance: none;
|
||
image-rendering: pixelated;
|
||
color: #5e6366 !important;
|
||
font-weight: 400 !important;
|
||
font-size: 15px !important;
|
||
text-shadow: none !important;
|
||
padding: 0 10px 10px !important;
|
||
line-height: 1 !important;
|
||
transition: padding-top .08s ease, padding-bottom .08s ease, filter .12s ease;
|
||
}
|
||
#control-bar #btn-open-drawer:hover {
|
||
background-color: transparent !important;
|
||
filter: brightness(1.04);
|
||
}
|
||
#control-bar #btn-open-drawer:active {
|
||
background-position: 50% 0 !important;
|
||
padding-top: 5px !important;
|
||
padding-bottom: 5px !important;
|
||
filter: brightness(0.97);
|
||
}
|
||
/* Guest Agent 名单面板(右下角) */
|
||
#guest-agent-panel {
|
||
position: relative;
|
||
width: 390px;
|
||
height: 300px;
|
||
background: #141722;
|
||
padding: 10px 10px 12px;
|
||
border-radius: 0;
|
||
border: 4px solid #0e1119;
|
||
box-shadow: none;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
overflow: hidden;
|
||
}
|
||
#guest-agent-panel::before {
|
||
content: '';
|
||
position: absolute;
|
||
inset: 0;
|
||
pointer-events: none;
|
||
background-image:
|
||
linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc),
|
||
linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc),
|
||
linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc),
|
||
linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc);
|
||
background-repeat: no-repeat;
|
||
background-size:
|
||
calc(50% - 14px) 2px, calc(50% - 14px) 2px,
|
||
2px calc(50% - 14px), 2px calc(50% - 14px),
|
||
calc(50% - 14px) 2px, calc(50% - 14px) 2px,
|
||
2px calc(50% - 14px), 2px calc(50% - 14px);
|
||
background-position:
|
||
9px 8px, calc(50% + 5px) 8px,
|
||
8px 9px, calc(100% - 10px) 9px,
|
||
9px calc(100% - 10px), calc(50% + 5px) calc(100% - 10px),
|
||
8px calc(50% + 5px), calc(100% - 10px) calc(50% + 5px);
|
||
}
|
||
#guest-agent-panel::after {
|
||
content: '';
|
||
position: absolute;
|
||
inset: 0;
|
||
pointer-events: none;
|
||
background-image:
|
||
linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc),
|
||
linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc),
|
||
linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc),
|
||
linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc);
|
||
background-repeat: no-repeat;
|
||
background-size:
|
||
9px 4px, 4px 9px,
|
||
9px 4px, 4px 9px,
|
||
9px 4px, 4px 9px,
|
||
9px 4px, 4px 9px;
|
||
background-position:
|
||
left top, left top,
|
||
right top, right top,
|
||
left bottom, left bottom,
|
||
right bottom, right bottom;
|
||
}
|
||
#guest-agent-panel-title {
|
||
color: #ffd700;
|
||
font-size: 16px;
|
||
font-weight: bold;
|
||
text-align: center;
|
||
letter-spacing: 1px;
|
||
padding: 6px 0 10px;
|
||
border-bottom: 0;
|
||
margin-bottom: 0;
|
||
}
|
||
#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 {
|
||
position: relative;
|
||
width: 460px;
|
||
height: 300px;
|
||
background-image: url('/static/memo-bg.webp');
|
||
background-size: cover;
|
||
background-position: center;
|
||
border: 4px solid #0e1119;
|
||
border-radius: 0;
|
||
padding: 14px 16px;
|
||
box-shadow: none;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
#memo-panel::before {
|
||
content: '';
|
||
position: absolute;
|
||
inset: 0;
|
||
pointer-events: none;
|
||
background-image:
|
||
linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc),
|
||
linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc),
|
||
linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc),
|
||
linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc);
|
||
background-repeat: no-repeat;
|
||
background-size:
|
||
calc(50% - 14px) 2px, calc(50% - 14px) 2px,
|
||
2px calc(50% - 14px), 2px calc(50% - 14px),
|
||
calc(50% - 14px) 2px, calc(50% - 14px) 2px,
|
||
2px calc(50% - 14px), 2px calc(50% - 14px);
|
||
background-position:
|
||
9px 8px, calc(50% + 5px) 8px,
|
||
8px 9px, calc(100% - 10px) 9px,
|
||
9px calc(100% - 10px), calc(50% + 5px) calc(100% - 10px),
|
||
8px calc(50% + 5px), calc(100% - 10px) calc(50% + 5px);
|
||
}
|
||
#memo-panel::after {
|
||
content: '';
|
||
position: absolute;
|
||
inset: 0;
|
||
pointer-events: none;
|
||
background-image:
|
||
linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc),
|
||
linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc),
|
||
linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc),
|
||
linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc);
|
||
background-repeat: no-repeat;
|
||
background-size:
|
||
9px 4px, 4px 9px,
|
||
9px 4px, 4px 9px,
|
||
9px 4px, 4px 9px,
|
||
9px 4px, 4px 9px;
|
||
background-position:
|
||
left top, left top,
|
||
right top, right top,
|
||
left bottom, left bottom,
|
||
right bottom, right bottom;
|
||
}
|
||
#memo-panel.no-bg {
|
||
background-image: none !important;
|
||
background-color: #111827;
|
||
}
|
||
#memo-title {
|
||
color: #1b192e;
|
||
font-size: 16px;
|
||
font-weight: bold;
|
||
margin-bottom: 6px;
|
||
text-align: center;
|
||
letter-spacing: 1px;
|
||
flex-shrink: 0;
|
||
position: relative;
|
||
top: 15px;
|
||
}
|
||
#memo-date {
|
||
color: #888;
|
||
font-size: 10px;
|
||
margin-bottom: 8px;
|
||
text-align: right;
|
||
flex-shrink: 0;
|
||
position: relative;
|
||
left: -40px; /* move date left by 40px */
|
||
top: -10px;
|
||
}
|
||
#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: 100px; /* move content right by 100px */
|
||
top: -10px;
|
||
}
|
||
#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: auto;
|
||
overflow-y: auto;
|
||
-webkit-overflow-scrolling: touch;
|
||
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: auto;
|
||
overflow: hidden;
|
||
}
|
||
|
||
#main-stage {
|
||
width: 100vw;
|
||
min-width: 0;
|
||
margin-left: 0 !important;
|
||
}
|
||
|
||
#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;
|
||
}
|
||
|
||
body.drawer-open #main-stage {
|
||
margin-left: 0 !important;
|
||
}
|
||
|
||
#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;
|
||
}
|
||
#control-buttons {
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
gap: 6px;
|
||
}
|
||
#control-buttons button {
|
||
min-height: 40px;
|
||
padding: 4px 2px;
|
||
font-size: 11px;
|
||
}
|
||
|
||
.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;
|
||
left: 8px;
|
||
max-width: 64vw;
|
||
font-size: 12px;
|
||
padding: 8px 12px;
|
||
}
|
||
|
||
#coords-toggle,
|
||
#pan-toggle,
|
||
#lang-btn-en,
|
||
#lang-btn-jp,
|
||
#lang-btn-cn {
|
||
font-size: 12px !important;
|
||
padding: 6px 8px !important;
|
||
}
|
||
|
||
#asset-drawer {
|
||
width: 92vw;
|
||
max-width: 92vw;
|
||
}
|
||
#asset-drawer-body { padding: 8px; }
|
||
#asset-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
.asset-item {
|
||
grid-template-columns: 52px 1fr 36px;
|
||
padding: 6px;
|
||
gap: 6px;
|
||
}
|
||
.asset-thumb { width:52px; height:52px; }
|
||
.asset-path { font-size: 11px; line-height: 1.3; }
|
||
.asset-sub { font-size: 10px; }
|
||
#asset-upload-panel {
|
||
position: sticky;
|
||
bottom: 0;
|
||
padding: 8px;
|
||
}
|
||
#asset-upload-panel .asset-toolbar {
|
||
gap: 6px;
|
||
margin-bottom: 6px;
|
||
}
|
||
#asset-upload-panel input {
|
||
min-width: 0;
|
||
flex: 1 1 42%;
|
||
}
|
||
#asset-upload-panel button {
|
||
min-height: 38px;
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<!-- 加载遮罩 -->
|
||
<div id="loading-overlay">
|
||
<div id="loading-text">Loading Star’s pixel office...</div>
|
||
<div id="loading-progress-container">
|
||
<div id="loading-progress-bar"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="main-stage">
|
||
<div id="game-container">
|
||
<div id="status-text">加载中...</div>
|
||
</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 id="btn-state-idle" onclick="setState('idle','待命')">待命</button>
|
||
<button id="btn-state-writing" onclick="setState('writing','工作中')">工作</button>
|
||
<button id="btn-state-syncing" onclick="setState('syncing','同步中')">同步</button>
|
||
<button id="btn-state-error" onclick="setState('error','报警中')">报警</button>
|
||
<button id="btn-open-drawer" onclick="toggleAssetDrawer()">装修房间</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>
|
||
|
||
<div id="asset-highlight"></div>
|
||
<div id="room-loading-overlay" aria-live="polite" aria-busy="true">
|
||
<div class="room-loading-inner">
|
||
<div id="room-loading-emoji">🦞</div>
|
||
<div id="room-loading-text">正在打包虾头……</div>
|
||
</div>
|
||
</div>
|
||
|
||
<aside id="asset-drawer">
|
||
<div id="asset-drawer-header">
|
||
<span>装修房间 · 资产侧边栏</span>
|
||
<button id="btn-close-drawer" onclick="toggleAssetDrawer(false)">关闭</button>
|
||
</div>
|
||
<div id="asset-drawer-body">
|
||
<div id="asset-auth-gate" class="asset-preview-box">
|
||
<div class="asset-preview-title">请输入装修验证码</div>
|
||
<div class="asset-toolbar">
|
||
<input id="asset-pass-input" type="password" placeholder="输入验证码" />
|
||
<button onclick="unlockAssetDrawer()">验证</button>
|
||
</div>
|
||
<div id="asset-auth-msg" class="asset-sub"></div>
|
||
</div>
|
||
|
||
<div id="asset-main-content" style="display:none;">
|
||
<div id="asset-move-panel">
|
||
<div class="asset-toolbar" id="asset-move-row">
|
||
<button id="btn-move-house" class="btn-move" onclick="generateRpgBackground()">📦 搬新家</button>
|
||
<button id="btn-back-home" class="btn-home" onclick="restoreHomeBackground()">🐚 回老家</button>
|
||
</div>
|
||
<div class="asset-toolbar" id="asset-broker-row">
|
||
<button class="btn-broker" onclick="toggleBrokerPanel()">🤝 找中介</button>
|
||
<button id="btn-diy" class="btn-diy" onclick="toggleManualPanel()">🪚 自己装</button>
|
||
</div>
|
||
<div id="asset-move-result" class="asset-sub" style="margin-top:4px; margin-bottom:6px;"></div>
|
||
<div id="asset-broker-panel">
|
||
<div class="asset-sub" style="margin-bottom:6px;">写你的风格主题(严格保持原始房间结构,只改变视觉风格)</div>
|
||
<textarea id="asset-broker-prompt" placeholder="例如:像素风赛博东京夜景,霓虹灯、雨夜地面反光、蓝紫主色"></textarea>
|
||
<div class="asset-toolbar" style="margin-top:6px; gap:8px; align-items:center; justify-content:flex-start;">
|
||
<span id="speed-mode-label" class="asset-sub" style="min-width:62px;">生成模式</span>
|
||
<button id="speed-fast-btn" type="button" onclick="setSpeedMode('fast')" style="background:#22c55e;color:#052e16;border-color:#16a34a;">🍌2</button>
|
||
<button id="speed-quality-btn" type="button" onclick="setSpeedMode('quality')" style="background:#334155;color:#e5e7eb;border-color:#475569;">🍌Pro</button>
|
||
</div>
|
||
<details id="asset-gemini-panel" style="margin-top:6px; border:1px dashed #334155; border-radius:8px; padding:8px; background:#0b1220;">
|
||
<summary id="gemini-panel-summary" style="cursor:pointer; color:#cbd5e1;">🔐 API 设置(可折叠)</summary>
|
||
<div id="asset-gemini-config" style="display:block; margin-top:6px;">
|
||
<div id="gemini-config-hint" class="asset-sub" style="margin-bottom:4px;">可选:填写你的生图 API Key(留空不影响基础功能)</div>
|
||
<div class="asset-sub" style="margin-bottom:6px;"><a id="gemini-api-doc-link" href="https://ai.google.dev/gemini-api/docs/api-key?hl=zh-cn" target="_blank" rel="noopener noreferrer">📘 如何申请 Google API Key</a></div>
|
||
<div id="gemini-mask-status" class="asset-sub" style="margin-bottom:6px; color:#a7f3d0;"></div>
|
||
<div class="asset-toolbar" style="gap:6px; flex-wrap:wrap;">
|
||
<input id="gemini-api-key-input" type="password" placeholder="粘贴 GEMINI_API_KEY(不会回显)" style="min-width:220px; flex:1;" autocomplete="new-password" />
|
||
<button id="btn-save-gemini-key" onclick="saveGeminiConfigFromUI()">保存 Key</button>
|
||
</div>
|
||
<div id="gemini-config-msg" class="asset-sub" style="margin-top:4px;"></div>
|
||
</div>
|
||
</details>
|
||
<div id="asset-broker-actions">
|
||
<button onclick="generateCustomRpgBackground()">按中介方案搬家</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="asset-home-actions-panel" class="asset-preview-box" style="margin-bottom:10px;">
|
||
<div class="asset-toolbar" style="margin-bottom:6px; gap:8px;">
|
||
<button id="btn-back-last-bg" class="btn-home" onclick="restoreLastGeneratedBackground()">↩️ 回上一个家</button>
|
||
<button id="btn-favorite-home" class="btn-home" onclick="saveCurrentHomeFavorite()">⭐ 收藏这个家</button>
|
||
</div>
|
||
<div id="asset-home-favorites" class="asset-preview-box" style="margin:0;">
|
||
<div id="asset-home-favorites-title" class="asset-preview-title">🏠 收藏的家</div>
|
||
<div id="asset-home-favorites-list" class="home-fav-list"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="asset-manual-panel">
|
||
<div class="asset-toolbar">
|
||
<input id="asset-search" placeholder="搜索资产名(如 desk / sofa / star)" oninput="renderAssetDrawerList()" />
|
||
</div>
|
||
<div id="asset-list"></div>
|
||
<div id="asset-upload-panel">
|
||
<input id="asset-upload-file" type="file" accept="image/*" style="display:none;" />
|
||
<div class="asset-toolbar" style="margin-top:0; margin-bottom:6px; gap:8px;">
|
||
<button id="asset-choose-btn" onclick="openInlineAssetUploader()">上传替换素材</button>
|
||
<button id="asset-commit-refresh-btn" onclick="commitAndRefresh()" disabled style="opacity:.55;">确认并刷新</button>
|
||
</div>
|
||
<div class="asset-toolbar" style="margin-top:0; margin-bottom:6px; gap:8px;">
|
||
<button id="asset-reset-default-btn" onclick="resetSelectedAssetToDefault()" disabled style="opacity:.55;">重置为默认资产</button>
|
||
<button id="asset-restore-prev-btn" onclick="restoreSelectedAssetPrev()" disabled style="opacity:.55;">用上一版</button>
|
||
</div>
|
||
<div id="asset-upload-result" class="asset-sub"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
<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>
|
||
<div id="lang-toggle-group" style="position:fixed; top:calc(env(safe-area-inset-top, 0px) + 52px); left:12px; z-index:999999; display:flex; gap:2px; align-items:center;">
|
||
<button id="lang-btn-en" onclick="setUILanguage('en')" style="padding:8px 10px; font-family:ArkPixel,monospace; font-size:13px; cursor:pointer; border:2px solid #333; border-radius:5px; background:#333; color:#fff;">EN</button>
|
||
<button id="lang-btn-jp" onclick="setUILanguage('ja')" style="padding:8px 10px; font-family:ArkPixel,monospace; font-size:13px; cursor:pointer; border:2px solid #333; border-radius:5px; background:#333; color:#fff;">JP</button>
|
||
<button id="lang-btn-cn" onclick="setUILanguage('zh')" style="padding:8px 10px; font-family:ArkPixel,monospace; font-size:13px; cursor:pointer; border:2px solid #333; border-radius:5px; background:#333; color:#fff;">CN</button>
|
||
</div>
|
||
|
||
<script src="/static/vendor/phaser-3.80.1.min.js"></script>
|
||
<script>
|
||
// 简易中英文切换
|
||
let uiLang = localStorage.getItem('uiLang') || 'en';
|
||
const I18N = {
|
||
zh: {
|
||
controlTitle: 'Star 状态',
|
||
btnIdle: '待命', btnWork: '工作', btnSync: '同步', btnError: '报警', btnDecor: '装修房间',
|
||
drawerTitle: '装修房间 · 资产侧边栏', drawerClose: '关闭',
|
||
authTitle: '请输入装修验证码', authPlaceholder: '输入验证码', authVerify: '验证', authDefaultPassHint: '默认密码:1234(可随时让我帮你改,建议改成强密码)',
|
||
drawerVisibilityTip: '可见性:点击条目右侧眼睛按钮切换该资产显示',
|
||
hideDrawer: '👁 隐藏侧边栏', showDrawer: '👁 显示侧边栏',
|
||
assetHide: '隐藏', assetShow: '显示',
|
||
resetToDefault: '重置为默认资产', restorePrevAsset: '用上一版',
|
||
btnMove: '📦 搬新家', btnHome: '🐚 回老家', btnHomeLast: '↩️ 回上一个家', btnHomeFavorite: '⭐ 收藏这个家', btnBroker: '🤝 找中介', btnDIY: '🪚 自己装', btnBrokerGo: '听中介的',
|
||
homeFavTitle: '🏠 收藏的家', homeFavEmpty: '还没有收藏,先点“⭐ 收藏这个家”', homeFavApply: '替换到当前地图', homeFavSaved: '✅ 已收藏当前地图', homeFavApplied: '✅ 已替换为收藏地图',
|
||
brokerHint: '你会给龙虾推荐什么样的房子',
|
||
brokerPromptPh: '例如:故宫主题、莫奈风格、地牢主题、兵马俑主题……',
|
||
brokerNeedPrompt: '请先输入中介方案描述',
|
||
brokerGenerating: '🏘️ 正在按中介方案生成底图,请稍候(约20-90秒)...',
|
||
brokerDone: '✅ 已按中介方案生成并替换底图,正在刷新房间...',
|
||
moveSuccess: '✅ 搬家成功!',
|
||
brokerMissingKey: '❌ 生图失败:缺少 GEMINI API Key,请在下方填写并保存后重试',
|
||
geminiPanelTitle: '🔐 API 设置(可折叠)', geminiHint: '可选:填写你的生图 API Key(留空不影响基础功能)', geminiApiDoc: '📘 如何申请 Google API Key', geminiInputPh: '粘贴 GEMINI_API_KEY(不会回显)', geminiSaveKey: '保存 Key', geminiMaskNoKey: '当前状态:未配置 Key', geminiMaskHasKey: '当前已配置:',
|
||
speedModeLabel: '生成模式', speedFast: '🍌2', speedQuality: '🍌Pro',
|
||
searchPlaceholder: '搜索资产名(如 desk / sofa / star)', loaded: '已加载', allAssets: '全部资产',
|
||
chooseImage: '上传替换素材', confirmUpload: '确认并刷新', uploadPending: '待上传', uploadTarget: '目标',
|
||
assetHintNotInScene: '当前场景未检测到此对象,仍可替换文件(刷新后生效)',
|
||
assetHintDefault: '通用素材:建议保持原图尺寸、透明通道与视觉重心一致,避免错位或失真',
|
||
showCoords: '显示坐标', hideCoords: '隐藏坐标', moveView: '移动视野', lockView: '锁定视野',
|
||
memoTitle: '昨 日 小 记', guestTitle: '访 客 列 表', officeTitle: '海辛小龙虾的办公室',
|
||
loadingOffice: '正在加载 Star 的像素办公室...'
|
||
},
|
||
en: {
|
||
controlTitle: 'Star Status',
|
||
btnIdle: 'Idle', btnWork: 'Work', btnSync: 'Sync', btnError: 'Alert', btnDecor: 'Decorate Room',
|
||
drawerTitle: 'Decorate Room · Asset Sidebar', drawerClose: 'Close',
|
||
authTitle: 'Enter Decor Passcode', authPlaceholder: 'Enter passcode', authVerify: 'Verify', authDefaultPassHint: 'Default passcode: 1234 (ask me anytime to change it; stronger passcode recommended)',
|
||
drawerVisibilityTip: 'Visibility: use the eye button on each row to hide/show that asset',
|
||
hideDrawer: '👁 Hide Drawer', showDrawer: '👁 Show Drawer',
|
||
assetHide: 'Hide', assetShow: 'Show',
|
||
resetToDefault: 'Reset to Default', restorePrevAsset: 'Use Previous',
|
||
btnMove: '📦 New Home', btnHome: '🐚 Go Home', btnHomeLast: '↩️ Last One', btnHomeFavorite: '⭐ Save This Home', btnBroker: '🤝 Broker', btnDIY: '🪚 DIY', btnBrokerGo: 'Follow Broker',
|
||
homeFavTitle: '🏠 Saved Homes', homeFavEmpty: 'No saved homes yet. Tap “⭐ Save This Home” first.', homeFavApply: 'Apply to Current Map', homeFavSaved: '✅ Current map saved', homeFavApplied: '✅ Applied saved home',
|
||
brokerHint: 'What kind of house would you recommend for Lobster?',
|
||
brokerPromptPh: 'e.g. Forbidden City theme, Monet style, dungeon theme, Terracotta Warriors theme...',
|
||
brokerNeedPrompt: 'Please enter broker style prompt first',
|
||
brokerGenerating: '🏘️ Generating room background from broker plan, please wait (20-90s)...',
|
||
brokerDone: '✅ Broker plan applied and background replaced, refreshing room...',
|
||
moveSuccess: '✅ Move successful!',
|
||
brokerMissingKey: '❌ Generation failed: missing GEMINI API key. Fill it below and retry.',
|
||
geminiPanelTitle: '🔐 API Settings (collapsible)', geminiHint: 'Optional: set your image API key (base features work without it)', geminiApiDoc: '📘 How to get a Google API Key', geminiInputPh: 'Paste GEMINI_API_KEY (input hidden)', geminiSaveKey: 'Save Key', geminiMaskNoKey: 'Current: no key configured', geminiMaskHasKey: 'Configured key:',
|
||
speedModeLabel: 'Render Mode', speedFast: '🍌2', speedQuality: '🍌Pro',
|
||
searchPlaceholder: 'Search assets (desk / sofa / star)', loaded: 'Loaded', allAssets: 'All Assets',
|
||
chooseImage: 'Upload Replacement Asset', confirmUpload: 'Confirm & Refresh', uploadPending: 'Pending Upload', uploadTarget: 'Target',
|
||
assetHintNotInScene: 'This object is not detected in current scene; you can still replace file (effective after refresh)',
|
||
assetHintDefault: 'Generic asset: keep source size, alpha channel, and visual anchor to avoid drift/distortion',
|
||
showCoords: 'Show Coords', hideCoords: 'Hide Coords', moveView: 'Pan View', lockView: 'Lock View',
|
||
memoTitle: 'YESTERDAY NOTES', guestTitle: 'VISITOR LIST', officeTitle: 'Haixin Lobster Office',
|
||
loadingOffice: 'Loading Star’s pixel office...'
|
||
},
|
||
ja: {
|
||
controlTitle: 'Star ステータス',
|
||
btnIdle: '待機', btnWork: '作業', btnSync: '同期', btnError: '警報', btnDecor: '部屋を編集',
|
||
drawerTitle: '部屋編集・アセットサイドバー', drawerClose: '閉じる',
|
||
authTitle: '編集パスコードを入力', authPlaceholder: 'パスコード入力', authVerify: '認証', authDefaultPassHint: '初期パスコード:1234(いつでも変更を相談可。強固なパス推奨)',
|
||
drawerVisibilityTip: '表示切替:各行右側の目ボタンで資産を表示/非表示',
|
||
hideDrawer: '👁 サイドバーを隠す', showDrawer: '👁 サイドバーを表示',
|
||
assetHide: '非表示', assetShow: '表示',
|
||
resetToDefault: 'デフォルトへ戻す', restorePrevAsset: '前の版へ戻す',
|
||
btnMove: '📦 引っ越し', btnHome: '🐚 実家に戻る', btnHomeLast: '↩️ ひとつ前へ', btnHomeFavorite: '⭐ この家を保存', btnBroker: '🤝 仲介', btnDIY: '🪚 自分で装飾', btnBrokerGo: '仲介に任せる',
|
||
homeFavTitle: '🏠 保存した家', homeFavEmpty: 'まだ保存がありません。先に「⭐ この家を保存」を押してください。', homeFavApply: '現在のマップに適用', homeFavSaved: '✅ 現在のマップを保存しました', homeFavApplied: '✅ 保存した家を適用しました',
|
||
brokerHint: 'ロブスターにはどんな家をおすすめしますか',
|
||
brokerPromptPh: '例:故宮テーマ、モネ風、ダンジョン風、兵馬俑テーマ…',
|
||
brokerNeedPrompt: '先に仲介プランの説明を入力してください',
|
||
brokerGenerating: '🏘️ 仲介プランで背景を生成中(20〜90秒)...',
|
||
brokerDone: '✅ 仲介プランを適用して背景を更新しました。部屋を更新中...',
|
||
moveSuccess: '✅ 引っ越し成功!',
|
||
brokerMissingKey: '❌ 生成失敗:GEMINI APIキーが未設定です。下で入力して保存してください。',
|
||
geminiPanelTitle: '🔐 API設定(折りたたみ)', geminiHint: '任意:画像生成APIキーを設定(未設定でも基本機能は利用可)', geminiApiDoc: '📘 Google API Keyの取得方法', geminiInputPh: 'GEMINI_API_KEY を貼り付け(入力は非表示)', geminiSaveKey: 'Keyを保存', geminiMaskNoKey: '現在:キー未設定', geminiMaskHasKey: '設定済みキー:',
|
||
speedModeLabel: '生成モード', speedFast: '🍌2', speedQuality: '🍌Pro',
|
||
searchPlaceholder: 'アセット検索(desk / sofa / star)', loaded: '読み込み済み', allAssets: '全アセット',
|
||
chooseImage: '差し替え素材をアップロード', confirmUpload: '確定して更新', uploadPending: 'アップロード待ち', uploadTarget: '対象',
|
||
assetHintNotInScene: '現在のシーンでこのオブジェクトは未検出です。ファイル差し替えは可能(更新後に反映)',
|
||
assetHintDefault: '汎用素材:元サイズ・透過・視覚アンカーを維持し、ズレや崩れを防いでください',
|
||
showCoords: '座標表示', hideCoords: '座標非表示', moveView: '視点移動', lockView: '視点固定',
|
||
memoTitle: '昨日のメモ', guestTitle: '訪問者リスト', officeTitle: 'ハイシン・ロブスターのオフィス',
|
||
loadingOffice: 'Star のピクセルオフィスを読み込み中...'
|
||
}
|
||
};
|
||
|
||
function t(key) { return (I18N[uiLang] && I18N[uiLang][key]) || key; }
|
||
function renderBootLoadingText(percent) {
|
||
const loadingEl = document.getElementById('loading-text');
|
||
if (!loadingEl) return;
|
||
const base = t('loadingOffice');
|
||
const p = Number.isFinite(percent) ? ` ${Math.max(0, Math.min(100, Math.round(percent)))}%` : '';
|
||
loadingEl.textContent = `${base}${p}`;
|
||
}
|
||
|
||
function ensureMemoBgVisible() {
|
||
const panel = document.getElementById('memo-panel');
|
||
if (!panel) return;
|
||
panel.style.backgroundImage = "url('/static/memo-bg.webp?v={{VERSION_TIMESTAMP}}')";
|
||
panel.classList.remove('no-bg');
|
||
}
|
||
|
||
function applyLanguage() {
|
||
const setText = (id, key) => { const el = document.getElementById(id); if (el) el.textContent = t(key); };
|
||
const setPh = (id, key) => { const el = document.getElementById(id); if (el) el.placeholder = t(key); };
|
||
|
||
setText('control-bar-title', 'controlTitle');
|
||
setText('btn-state-idle', 'btnIdle');
|
||
setText('btn-state-writing', 'btnWork');
|
||
setText('btn-state-syncing', 'btnSync');
|
||
setText('btn-state-error', 'btnError');
|
||
setText('btn-open-drawer', 'btnDecor');
|
||
const langButtons = [
|
||
{ id: 'lang-btn-en', lang: 'en' },
|
||
{ id: 'lang-btn-jp', lang: 'ja' },
|
||
{ id: 'lang-btn-cn', lang: 'zh' }
|
||
];
|
||
langButtons.forEach(({ id, lang }) => {
|
||
const el = document.getElementById(id);
|
||
if (!el) return;
|
||
const active = (uiLang === lang);
|
||
el.style.background = active ? '#22c55e' : '#333';
|
||
el.style.borderColor = active ? '#22c55e' : '#333';
|
||
el.style.color = '#fff';
|
||
});
|
||
|
||
const drawerTitle = document.querySelector('#asset-drawer-header span');
|
||
if (drawerTitle) drawerTitle.textContent = t('drawerTitle');
|
||
const drawerClose = document.getElementById('btn-close-drawer');
|
||
if (drawerClose) drawerClose.textContent = t('drawerClose');
|
||
|
||
const authTitle = document.querySelector('#asset-auth-gate .asset-preview-title');
|
||
if (authTitle) authTitle.textContent = t('authTitle');
|
||
setPh('asset-pass-input', 'authPlaceholder');
|
||
const authVerifyBtn = document.querySelector('#asset-auth-gate .asset-toolbar button');
|
||
if (authVerifyBtn) authVerifyBtn.textContent = t('authVerify');
|
||
|
||
setText('btn-move-house', 'btnMove');
|
||
setText('btn-back-home', 'btnHome');
|
||
const brokerBtn = document.querySelector('#asset-broker-row .btn-broker'); if (brokerBtn) brokerBtn.textContent = t('btnBroker');
|
||
const diyBtn = document.querySelector('#asset-broker-row .btn-diy'); if (diyBtn) diyBtn.textContent = t('btnDIY');
|
||
const backLastBtn = document.getElementById('btn-back-last-bg'); if (backLastBtn) backLastBtn.textContent = t('btnHomeLast');
|
||
const favHomeBtn = document.getElementById('btn-favorite-home'); if (favHomeBtn) favHomeBtn.textContent = t('btnHomeFavorite');
|
||
const favTitle = document.getElementById('asset-home-favorites-title'); if (favTitle) favTitle.textContent = t('homeFavTitle');
|
||
const brokerHint = document.querySelector('#asset-broker-panel .asset-sub'); if (brokerHint) brokerHint.textContent = t('brokerHint');
|
||
const brokerPrompt = document.getElementById('asset-broker-prompt'); if (brokerPrompt) brokerPrompt.placeholder = t('brokerPromptPh');
|
||
const brokerGoBtn = document.querySelector('#asset-broker-actions button'); if (brokerGoBtn) brokerGoBtn.textContent = t('btnBrokerGo');
|
||
const speedLbl = document.getElementById('speed-mode-label'); if (speedLbl) speedLbl.textContent = t('speedModeLabel');
|
||
const speedFastBtn = document.getElementById('speed-fast-btn'); if (speedFastBtn) speedFastBtn.textContent = t('speedFast');
|
||
const speedQualityBtn = document.getElementById('speed-quality-btn'); if (speedQualityBtn) speedQualityBtn.textContent = t('speedQuality');
|
||
const geminiPanelSummary = document.getElementById('gemini-panel-summary'); if (geminiPanelSummary) geminiPanelSummary.textContent = t('geminiPanelTitle');
|
||
const geminiHint = document.getElementById('gemini-config-hint'); if (geminiHint) geminiHint.textContent = t('geminiHint');
|
||
const geminiDocLink = document.getElementById('gemini-api-doc-link'); if (geminiDocLink) geminiDocLink.textContent = t('geminiApiDoc');
|
||
const geminiInput = document.getElementById('gemini-api-key-input'); if (geminiInput) geminiInput.placeholder = t('geminiInputPh');
|
||
const geminiSaveBtn = document.getElementById('btn-save-gemini-key'); if (geminiSaveBtn) geminiSaveBtn.textContent = t('geminiSaveKey');
|
||
|
||
setPh('asset-search', 'searchPlaceholder');
|
||
|
||
setText('asset-choose-btn', 'chooseImage');
|
||
setText('asset-commit-refresh-btn', 'confirmUpload');
|
||
|
||
const memoTitle = document.getElementById('memo-title');
|
||
if (memoTitle) memoTitle.textContent = t('memoTitle');
|
||
const guestTitle = document.getElementById('guest-agent-panel-title');
|
||
if (guestTitle) guestTitle.textContent = t('guestTitle');
|
||
if (window.officePlaqueText && window.officePlaqueText.setText) {
|
||
window.officePlaqueText.setText(t('officeTitle'));
|
||
}
|
||
|
||
const coordsBtn = document.getElementById('coords-toggle');
|
||
if (coordsBtn) coordsBtn.textContent = showCoords ? t('hideCoords') : t('showCoords');
|
||
const panBtn = document.getElementById('pan-toggle');
|
||
if (panBtn) {
|
||
const on = panBtn.dataset.on === '1';
|
||
panBtn.textContent = on ? t('lockView') : t('moveView');
|
||
}
|
||
ensureMemoBgVisible();
|
||
renderBootLoadingText(Number(loadingProgressBar?.style?.width?.replace('%','') || 0));
|
||
}
|
||
|
||
function setUILanguage(lang) {
|
||
if (!['zh', 'en', 'ja'].includes(lang)) return;
|
||
uiLang = lang;
|
||
localStorage.setItem('uiLang', uiLang);
|
||
applyLanguage();
|
||
updateSpeedModeUI();
|
||
|
||
// 语言切换后立即重绘资产侧栏,确保易懂名同步更新
|
||
renderAssetDrawerList();
|
||
|
||
// 语言切换后同步刷新已选资产的指导文案(上传区小字三语联动)
|
||
if (selectedAssetInfo && selectedAssetInfo.path) {
|
||
const inScene = !!mapAssetPathToSprite(selectedAssetInfo.path);
|
||
renderSelectedAssetGuidance(selectedAssetInfo.path, inScene);
|
||
}
|
||
|
||
// 语言切换时,当前正在显示的 loading 文案也实时切换
|
||
const overlay = document.getElementById('room-loading-overlay');
|
||
if (overlay && overlay.style.display === 'flex') {
|
||
showRoomLoadingOverlay();
|
||
}
|
||
}
|
||
|
||
// 检测浏览器是否支持 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==';
|
||
});
|
||
}
|
||
|
||
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,并在相机里按高度做 fit(可横向 pan)
|
||
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) {
|
||
renderBootLoadingText(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 = {
|
||
zh: {
|
||
idle: ['待命中:耳朵竖起来了','我在这儿,随时可以开工','先把桌面收拾干净再说','呼——给大脑放个风','今天也要优雅地高效','等待,是为了更准确的一击','咖啡还热,灵感也还在','我在后台给你加 Buff','状态:静心 / 充电','小猫说:慢一点也没关系'],
|
||
writing: ['进入专注模式:勿扰','先把关键路径跑通','我来把复杂变简单','把 bug 关进笼子里','写到一半,先保存','把每一步都做成可回滚','今天的进度,明天的底气','先收敛,再发散','让系统变得更可解释','稳住,我们能赢'],
|
||
researching: ['我在挖证据链','让我把信息熬成结论','找到了:关键在这里','先把变量控制住','我在查:它为什么会这样','把直觉写成验证','先定位,再优化','别急,先画因果图'],
|
||
executing: ['执行中:不要眨眼','把任务切成小块逐个击破','开始跑 pipeline','一键推进:走你','让结果自己说话','先做最小可行,再做最美版本'],
|
||
syncing: ['同步中:把今天锁进云里','备份不是仪式,是安全感','写入中…别断电','把变更交给时间戳','云端对齐:咔哒','同步完成前先别乱动','把未来的自己从灾难里救出来','多一份备份,少一份后悔'],
|
||
error: ['警报响了:先别慌','我闻到 bug 的味道了','先复现,再谈修复','把日志给我,我会说人话','错误不是敌人,是线索','把影响面圈起来','先止血,再手术','我在:马上定位根因','别怕,这种我见多了','报警中:让问题自己现形'],
|
||
cat: ['喵~','咕噜咕噜…','尾巴摇一摇','晒太阳最开心','有人来看我啦','我是这个办公室的吉祥物','伸个懒腰','今天的罐罐准备好了吗','呼噜呼噜','这个位置视野最好']
|
||
},
|
||
en: {
|
||
idle: ['On standby: ears up.','I’m here, ready to roll.','Let’s tidy the desk first.','Taking a quick brain breeze.','Efficient and elegant, as always.','Waiting for a more precise strike.','Coffee is warm, ideas too.','Giving you a quiet backstage buff.','Status: calm / charging.','Cat says: no rush, we’re good.'],
|
||
writing: ['Focus mode on: do not disturb.','Let’s clear the critical path first.','I’ll make the complex simple.','Putting bugs in a cage.','Save first, then continue.','Every step should be rollback-safe.','Today’s progress is tomorrow’s confidence.','Converge first, then diverge.','Making the system more explainable.','Steady—this is winnable.'],
|
||
researching: ['Digging the evidence chain.','Let me boil info into conclusions.','Found it: key clue here.','Control variables first.','Checking why this happens.','Turn intuition into verification.','Locate first, optimize next.','No rush—draw the causality map first.'],
|
||
executing: ['Executing—don’t blink.','Split tasks, conquer one by one.','Pipeline is running.','One-click push: go go.','Let the results speak.','Build MVP first, then craft beauty.'],
|
||
syncing: ['Syncing: lock today into the cloud.','Backup is safety, not ceremony.','Writing… don’t cut power.','Handing changes to timestamps.','Cloud alignment: click.','Don’t shake it before sync finishes.','Saving future-us from disasters.','One more backup, one less regret.'],
|
||
error: ['Alarm on—stay calm.','I can smell a bug.','Reproduce first, then fix.','Give me logs; I’ll translate.','Errors are clues, not enemies.','Circle the impact area first.','Stop the bleeding, then surgery.','On it: tracing root cause now.','Don’t worry, seen this many times.','Alert mode: make the issue reveal itself.'],
|
||
cat: ['Meow~','Purr purr…','Tail wiggle activated.','Sunbathing is the best.','Someone came to see me!','I’m the office mascot.','Big stretch~','Is today’s snack ready yet?','Rrrrr purr…','Best view spot secured.']
|
||
},
|
||
ja: {
|
||
idle: ['待機中:耳はピン。','ここにいるよ、いつでも開始OK。','まず机を整えよう。','ふー、頭に風を通す。','今日も上品に高効率で。','待つのは、より正確な一撃のため。','コーヒーも発想もまだ温かい。','裏側でそっとバフ中。','状態:静心 / 充電。','猫より:ゆっくりでも大丈夫。'],
|
||
writing: ['集中モード:お静かに。','まずはクリティカルパスを通す。','複雑をシンプルにする。','バグはケージへ。','途中でもまず保存。','すべてをロールバック可能に。','今日の進捗は明日の自信。','まず収束、次に発散。','システムをより説明可能に。','落ち着いて、勝てる。'],
|
||
researching: ['証拠チェーンを掘っています。','情報を結論まで煮詰めます。','見つけた:鍵はここ。','まず変数を制御。','なぜこうなるか調査中。','直感を検証へ。','先に特定、次に最適化。','急がず因果マップから。'],
|
||
executing: ['実行中:まばたき厳禁。','タスクを分割して各個撃破。','パイプライン起動。','ワンクリック前進:いくぞ。','結果に語らせる。','まず最小実用、次に美しさ。'],
|
||
syncing: ['同期中:今日をクラウドに封印。','バックアップは儀式じゃなく安心。','書き込み中…電源オフ厳禁。','変更はタイムスタンプへ。','クラウド整列:カチッ。','同期完了まで触らないで。','未来の自分を災害から救う。','バックアップ一つ、後悔一つ減る。'],
|
||
error: ['警報:まず落ち着いて。','バグの気配を感じる。','再現してから修正へ。','ログをください、人語にします。','エラーは敵ではなく手がかり。','まず影響範囲を囲う。','止血してから手術。','今すぐ根因を追跡中。','大丈夫、よくある案件。','警戒モード:問題を可視化する。'],
|
||
cat: ['ニャー','ゴロゴロ…','しっぽフリフリ。','ひなたぼっこ最高。','見に来てくれた!','このオフィスのマスコットです。','ぐーっと伸び。','今日のおやつ、準備できた?','ゴロゴロ。','ここ、いちばん見晴らしがいい。']
|
||
}
|
||
};
|
||
|
||
let game, star, sofa, serverroom, officeBgSprite, 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, syncAnimPlayable = false, catBubble = null, selectionBoxGraphics = null;
|
||
const IDLE_SOFA_ANCHOR = { x: 798, y: 272 }; // 统一中心锚点(原 sofa 左上 670,144 的中心)
|
||
const IDLE_STAR_SCALE = 1.0; // star idle 改为256帧原生显示,不再放大
|
||
// flowers 精灵表规格:固定单帧 128x128,4x4
|
||
let FLOWERS_FRAME_W = 65;
|
||
let FLOWERS_FRAME_H = 65;
|
||
let FLOWERS_FRAME_COLS = 4;
|
||
let FLOWERS_FRAME_ROWS = 4;
|
||
let currentOfficeBgTextureKey = 'office_bg';
|
||
let assetDrawerOpen = false;
|
||
let assetDrawerAuthed = false;
|
||
let assetManualPanelOpen = false;
|
||
let assetFilterMode = 'all';
|
||
let assetListData = [];
|
||
let sceneAssetItems = [];
|
||
let selectedAssetInfo = null;
|
||
let hiddenAssetPaths = new Set();
|
||
let assetThumbTimers = [];
|
||
let homeFavoritesCache = [];
|
||
let homeFavoritesLoadedAt = 0;
|
||
|
||
// 坐标以服务端为准;清理历史本地缓存,避免把素材挪飞
|
||
let assetPositionOverrides = {};
|
||
let roomLoadingTimer = null;
|
||
let roomLoadingIndex = 0;
|
||
let roomLoadingEmojiIndex = 0;
|
||
|
||
// 默认走更稳的模型档(quality),避免部分通道不支持 fast 模型时报错
|
||
let speedMode = localStorage.getItem('speedMode') || 'quality';
|
||
function setSpeedMode(mode) {
|
||
speedMode = (mode === 'quality') ? 'quality' : 'fast';
|
||
try { localStorage.setItem('speedMode', speedMode); } catch(e) {}
|
||
updateSpeedModeUI();
|
||
}
|
||
function updateSpeedModeUI() {
|
||
const fastBtn = document.getElementById('speed-fast-btn');
|
||
const qBtn = document.getElementById('speed-quality-btn');
|
||
if (!fastBtn || !qBtn) return;
|
||
const fastOn = speedMode === 'fast';
|
||
fastBtn.style.background = fastOn ? '#22c55e' : '#334155';
|
||
fastBtn.style.color = fastOn ? '#052e16' : '#e5e7eb';
|
||
fastBtn.style.borderColor = fastOn ? '#16a34a' : '#475569';
|
||
qBtn.style.background = fastOn ? '#334155' : '#22c55e';
|
||
qBtn.style.color = fastOn ? '#e5e7eb' : '#052e16';
|
||
qBtn.style.borderColor = fastOn ? '#475569' : '#16a34a';
|
||
}
|
||
try { localStorage.removeItem('assetPositionOverrides'); } catch (e) {}
|
||
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((res) => {
|
||
if (!res.ok) throw new Error(`set_state failed: ${res.status}`);
|
||
return fetchStatus();
|
||
})
|
||
.catch((e) => {
|
||
console.error('setState failed', e);
|
||
});
|
||
}
|
||
|
||
function updateAssetAuthUI() {
|
||
const gate = document.getElementById('asset-auth-gate');
|
||
const main = document.getElementById('asset-main-content');
|
||
if (!gate || !main) return;
|
||
gate.style.display = assetDrawerAuthed ? 'none' : 'block';
|
||
main.style.display = assetDrawerAuthed ? 'block' : 'none';
|
||
updateManualPanelUI();
|
||
}
|
||
|
||
function updateManualPanelUI() {
|
||
const panel = document.getElementById('asset-manual-panel');
|
||
if (!panel) return;
|
||
panel.classList.toggle('open', !!assetManualPanelOpen && !!assetDrawerAuthed);
|
||
}
|
||
|
||
async function unlockAssetDrawer() {
|
||
const input = document.getElementById('asset-pass-input');
|
||
const msg = document.getElementById('asset-auth-msg');
|
||
const val = (input?.value || '').trim();
|
||
if (!val) {
|
||
if (msg) msg.textContent = '❌ 请输入验证码';
|
||
return;
|
||
}
|
||
try {
|
||
const res = await fetch('/assets/auth', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ password: val })
|
||
});
|
||
const data = await res.json();
|
||
if (data && data.ok) {
|
||
assetDrawerAuthed = true;
|
||
if (msg) msg.textContent = '✅ 验证通过';
|
||
updateAssetAuthUI();
|
||
await refreshAssetDrawerList();
|
||
bindDrawerFileMeta();
|
||
} else {
|
||
assetDrawerAuthed = false;
|
||
if (msg) msg.textContent = '❌ 验证码错误';
|
||
}
|
||
} catch (e) {
|
||
assetDrawerAuthed = false;
|
||
if (msg) msg.textContent = `❌ 验证失败:${e}`;
|
||
}
|
||
}
|
||
|
||
function formatSizeHuman(n) {
|
||
if (!n) return '0 KB';
|
||
if (n >= 1024 * 1024) return (n / 1024 / 1024).toFixed(2) + ' MB';
|
||
return (n / 1024).toFixed(1) + ' KB';
|
||
}
|
||
function toAssetStem(v) {
|
||
const s = (v || '').toLowerCase();
|
||
const file = s.split('/').pop() || s;
|
||
return file.replace(/\.[^.]+$/, '');
|
||
}
|
||
|
||
function getAssetDisplayName(path) {
|
||
const stem = toAssetStem(path);
|
||
const lang = (uiLang || 'zh');
|
||
const nameMap = {
|
||
zh: {
|
||
'star-idle-v5': '主角·待命状态',
|
||
'star-working-spritesheet-grid': '主角·工作状态',
|
||
'sync-animation': '主角·同步状态',
|
||
'sync-animation-v3-grid': '主角·同步状态',
|
||
'error-bug-spritesheet-grid': '主角·报错状态',
|
||
'cats-spritesheet': '随机猫猫',
|
||
'coffee-machine-v3-grid': '咖啡机',
|
||
'coffee-machine-shadow-v1': '咖啡机阴影',
|
||
'posters-spritesheet': '随机海报',
|
||
'serverroom-spritesheet': '服务器房动画',
|
||
'plants-spritesheet': '随机绿植',
|
||
'flowers-bloom-v2': '随机花朵',
|
||
'office_bg_small': '办公室背景',
|
||
'memo-bg': '昨日小记底图',
|
||
'desk-v3': '办公桌',
|
||
'desk': '办公桌(旧)',
|
||
'guest_anim_1': '访客动画 1',
|
||
'guest_anim_2': '访客动画 2',
|
||
'guest_anim_3': '访客动画 3',
|
||
'guest_anim_4': '访客动画 4',
|
||
'guest_anim_5': '访客动画 5',
|
||
'guest_anim_6': '访客动画 6'
|
||
},
|
||
en: {
|
||
'star-idle-v5': 'Main · Idle',
|
||
'star-working-spritesheet-grid': 'Main · Working',
|
||
'sync-animation': 'Main · Syncing',
|
||
'sync-animation-v3-grid': 'Main · Syncing',
|
||
'error-bug-spritesheet-grid': 'Main · Error',
|
||
'cats-spritesheet': 'Random Cats',
|
||
'coffee-machine-v3-grid': 'Coffee Machine',
|
||
'coffee-machine-shadow-v1': 'Coffee Machine Shadow',
|
||
'posters-spritesheet': 'Random Posters',
|
||
'serverroom-spritesheet': 'Server Room',
|
||
'plants-spritesheet': 'Random Plants',
|
||
'flowers-bloom-v2': 'Random Flowers',
|
||
'office_bg_small': 'Office Background',
|
||
'memo-bg': 'Memo Background',
|
||
'desk-v3': 'Desk',
|
||
'desk': 'Desk (Old)',
|
||
'guest_anim_1': 'Guest Animation 1',
|
||
'guest_anim_2': 'Guest Animation 2',
|
||
'guest_anim_3': 'Guest Animation 3',
|
||
'guest_anim_4': 'Guest Animation 4',
|
||
'guest_anim_5': 'Guest Animation 5',
|
||
'guest_anim_6': 'Guest Animation 6'
|
||
},
|
||
ja: {
|
||
'star-idle-v5': 'メイン・待機状態',
|
||
'star-working-spritesheet-grid': 'メイン・作業状態',
|
||
'sync-animation': 'メイン・同期状態',
|
||
'sync-animation-v3-grid': 'メイン・同期状態',
|
||
'error-bug-spritesheet-grid': 'メイン・エラー状態',
|
||
'cats-spritesheet': 'ランダム猫',
|
||
'coffee-machine-v3-grid': 'コーヒーマシン',
|
||
'coffee-machine-shadow-v1': 'コーヒーマシン影',
|
||
'posters-spritesheet': 'ランダムポスター',
|
||
'serverroom-spritesheet': 'サーバールーム',
|
||
'plants-spritesheet': 'ランダム植物',
|
||
'flowers-bloom-v2': 'ランダム花',
|
||
'office_bg_small': 'オフィス背景',
|
||
'memo-bg': 'メモ背景',
|
||
'desk-v3': 'デスク',
|
||
'desk': 'デスク(旧)',
|
||
'guest_anim_1': '訪客アニメ 1',
|
||
'guest_anim_2': '訪客アニメ 2',
|
||
'guest_anim_3': '訪客アニメ 3',
|
||
'guest_anim_4': '訪客アニメ 4',
|
||
'guest_anim_5': '訪客アニメ 5',
|
||
'guest_anim_6': '訪客アニメ 6'
|
||
}
|
||
};
|
||
const langMap = nameMap[lang] || nameMap.zh;
|
||
return langMap[stem] || stem;
|
||
}
|
||
|
||
const ASSET_HELP_TEXT_MAP = {
|
||
zh: {
|
||
'office_bg_small': '主场景底图(当前生效)。建议 1280×720(16:9),保留房间结构与视角,避免角色站位错位。',
|
||
'office_bg': '历史背景备份。通常不直接生效,建议与 office_bg_small 保持同构图用于回退。',
|
||
'star-idle-v5': '主角待机动画表。请保持 256×256 分帧与网格布局一致,否则待机动作会错帧。',
|
||
'star-working-spritesheet-grid': '主角工作动画表(工位状态)。请保持 300×300 分帧,建议人物重心与原图一致。',
|
||
'sync-animation': '同步状态素材(当前引用)。建议按 256×256 帧规范制作,避免同步状态显示静止或抖动。',
|
||
'sync-animation-v3-grid': '同步动画表(兼容资源)。保持 256×256 网格可用于替换同步动作细节。',
|
||
'error-bug-spritesheet-grid': '报错状态动画表。请保持 220×220 分帧,建议高对比度以增强异常提示感。',
|
||
'desk-v3': '办公桌前景层。影响主角前后遮挡关系,建议保持当前比例与锚点视觉重心。',
|
||
'desk': '旧版办公桌素材(兼容用)。建议与 desk-v3 保持相近体积与锚点,避免遮挡异常。',
|
||
'sofa-idle-v3': '沙发静态素材。建议保持 256×256 与透明背景,避免替换后位置漂移。',
|
||
'sofa-shadow-v1': '沙发阴影层。建议与沙发主体同坐标叠放,增强贴地感。',
|
||
'memo-bg': '小记面板底图。建议留出文字阅读区域,降低高频纹理,避免信息难读。',
|
||
'plants-spritesheet': '绿植随机素材。保持 160×160 分帧,可一次替换多个绿植位的观感。',
|
||
'posters-spritesheet': '海报随机素材。保持 160×160 分帧,建议统一风格避免墙面杂乱。',
|
||
'cats-spritesheet': '猫咪随机素材。保持 160×160 分帧,建议轮廓清晰、识别度高。',
|
||
'coffee-machine-v3-grid': '咖啡机静态素材。建议保持 230×230 与当前锚点,避免位置偏移。',
|
||
'coffee-machine-shadow-v1': '咖啡机阴影层。建议与咖啡机本体同宽对齐,增强贴地感。',
|
||
'serverroom-spritesheet': '服务器房动画表。保持 180×251 分帧,灯效变化建议节奏均匀不过闪。',
|
||
'flowers-bloom-v2': '花朵随机素材。保持 128×128 分帧,建议色彩与整体办公室主色协调。',
|
||
'guest_anim_1': '访客动画序列 1(32×32 分帧)。建议保持像素风、轮廓清晰,与主角风格统一。',
|
||
'guest_anim_2': '访客动画序列 2(32×32 分帧)。建议保持像素风、轮廓清晰,与主角风格统一。',
|
||
'guest_anim_3': '访客动画序列 3(32×32 分帧)。建议保持像素风、轮廓清晰,与主角风格统一。',
|
||
'guest_anim_4': '访客动画序列 4(32×32 分帧)。建议保持像素风、轮廓清晰,与主角风格统一。',
|
||
'guest_anim_5': '访客动画序列 5(32×32 分帧)。建议保持像素风、轮廓清晰,与主角风格统一。',
|
||
'guest_anim_6': '访客动画序列 6(32×32 分帧)。建议保持像素风、轮廓清晰,与主角风格统一。',
|
||
'guest_role_1': '访客静态形象备用图 1。建议与对应 guest_anim 角色设定一致,避免切换割裂。',
|
||
'guest_role_2': '访客静态形象备用图 2。建议与对应 guest_anim 角色设定一致,避免切换割裂。',
|
||
'guest_role_3': '访客静态形象备用图 3。建议与对应 guest_anim 角色设定一致,避免切换割裂。',
|
||
'guest_role_4': '访客静态形象备用图 4。建议与对应 guest_anim 角色设定一致,避免切换割裂。',
|
||
'guest_role_5': '访客静态形象备用图 5。建议与对应 guest_anim 角色设定一致,避免切换割裂。',
|
||
'guest_role_6': '访客静态形象备用图 6。建议与对应 guest_anim 角色设定一致,避免切换割裂。'
|
||
},
|
||
en: {
|
||
'office_bg_small': 'Primary room background (active). Use 1280×720 (16:9), keep room structure/perspective to avoid character misalignment.',
|
||
'office_bg': 'Legacy backup background. Usually not directly active; keep composition aligned with office_bg_small for rollback.',
|
||
'star-idle-v5': 'Main idle spritesheet. Keep 256×256 frame size and grid layout, or idle animation will break.',
|
||
'star-working-spritesheet-grid': 'Main working spritesheet (desk state). Keep 300×300 frames; preserve visual center/anchor.',
|
||
'sync-animation': 'Sync-state asset (currently referenced). Follow 256×256 frame spec to avoid static/jitter sync visuals.',
|
||
'sync-animation-v3-grid': 'Sync spritesheet (compat resource). Keep 256×256 grid for sync animation replacement.',
|
||
'error-bug-spritesheet-grid': 'Error-state spritesheet. Keep 220×220 frames; high contrast helps warning readability.',
|
||
'desk-v3': 'Desk foreground layer. Controls overlap with character; keep ratio and visual anchor stable.',
|
||
'desk': 'Legacy desk asset (compatibility). Keep size/anchor close to desk-v3 to avoid overlap issues.',
|
||
'sofa-idle-v3': 'Static sofa asset. Keep 256×256 and transparent background to prevent position drift.',
|
||
'sofa-shadow-v1': 'Sofa shadow layer. Keep the exact same coordinates as sofa body for grounded feel.',
|
||
'memo-bg': 'Memo panel background. Reserve readable text area; avoid dense textures behind text.',
|
||
'plants-spritesheet': 'Random plant sprites. Keep 160×160 frames; updates several plant spots at once.',
|
||
'posters-spritesheet': 'Random poster sprites. Keep 160×160 frames; prefer consistent style to avoid wall clutter.',
|
||
'cats-spritesheet': 'Random cat sprites. Keep 160×160 frames; clear silhouette improves recognition.',
|
||
'coffee-machine-v3-grid': 'Static coffee machine asset. Keep 230×230 size and anchor to avoid drift.',
|
||
'coffee-machine-shadow-v1': 'Coffee machine shadow layer. Align width/anchor with the machine body for grounded feel.',
|
||
'serverroom-spritesheet': 'Server-room animation sheet. Keep 180×251 frames; avoid over-flickering lights.',
|
||
'flowers-bloom-v2': 'Random flower sprites. Keep 128×128 frames; align palette with overall office mood.',
|
||
'guest_anim_1': 'Guest animation set 1 (32×32 frames). Keep pixel-art style/outline consistent with main cast.',
|
||
'guest_anim_2': 'Guest animation set 2 (32×32 frames). Keep pixel-art style/outline consistent with main cast.',
|
||
'guest_anim_3': 'Guest animation set 3 (32×32 frames). Keep pixel-art style/outline consistent with main cast.',
|
||
'guest_anim_4': 'Guest animation set 4 (32×32 frames). Keep pixel-art style/outline consistent with main cast.',
|
||
'guest_anim_5': 'Guest animation set 5 (32×32 frames). Keep pixel-art style/outline consistent with main cast.',
|
||
'guest_anim_6': 'Guest animation set 6 (32×32 frames). Keep pixel-art style/outline consistent with main cast.',
|
||
'guest_role_1': 'Fallback static guest avatar 1. Keep design aligned with corresponding guest_anim for smooth fallback.',
|
||
'guest_role_2': 'Fallback static guest avatar 2. Keep design aligned with corresponding guest_anim for smooth fallback.',
|
||
'guest_role_3': 'Fallback static guest avatar 3. Keep design aligned with corresponding guest_anim for smooth fallback.',
|
||
'guest_role_4': 'Fallback static guest avatar 4. Keep design aligned with corresponding guest_anim for smooth fallback.',
|
||
'guest_role_5': 'Fallback static guest avatar 5. Keep design aligned with corresponding guest_anim for smooth fallback.',
|
||
'guest_role_6': 'Fallback static guest avatar 6. Keep design aligned with corresponding guest_anim for smooth fallback.'
|
||
},
|
||
ja: {
|
||
'office_bg_small': 'メイン背景(現在有効)。1280×720(16:9)推奨。部屋構造と視点を維持し、キャラの位置ズレを防いでください。',
|
||
'office_bg': '旧背景のバックアップ。通常は直接反映されません。office_bg_small と同構図で保持すると復旧しやすいです。',
|
||
'star-idle-v5': 'メイン待機スプライトシート。256×256 分割とグリッド構成を維持しないと待機アニメが崩れます。',
|
||
'star-working-spritesheet-grid': 'メイン作業スプライトシート(デスク状態)。300×300 分割を維持し、重心位置を揃えてください。',
|
||
'sync-animation': '同期状態素材(現在参照中)。256×256 仕様を守ると静止/ガタつきを回避できます。',
|
||
'sync-animation-v3-grid': '同期スプライトシート(互換用)。256×256 グリッド維持で同期演出を差し替え可能です。',
|
||
'error-bug-spritesheet-grid': 'エラー状態スプライトシート。220×220 分割を維持し、視認性の高い配色を推奨。',
|
||
'desk-v3': 'デスク前景レイヤー。キャラとの前後関係に影響するため、比率と視覚アンカーを維持してください。',
|
||
'desk': '旧デスク素材(互換)。desk-v3 に近いサイズ/アンカーで差し替えると崩れにくいです。',
|
||
'sofa-idle-v3': 'ソファ静止素材。256×256 と透過背景を維持し、位置ズレを防いでください。',
|
||
'sofa-shadow-v1': 'ソファ影レイヤー。本体と同座標に重ねると接地感が出ます。',
|
||
'memo-bg': 'メモパネル背景。文字可読域を確保し、細かすぎる模様は避けてください。',
|
||
'plants-spritesheet': '植物ランダム素材。160×160 分割を維持すると複数の植物表示を一括更新できます。',
|
||
'posters-spritesheet': 'ポスターランダム素材。160×160 分割を維持し、壁面の統一感を意識してください。',
|
||
'cats-spritesheet': '猫ランダム素材。160×160 分割を維持し、シルエットを明確にすると見分けやすいです。',
|
||
'coffee-machine-v3-grid': 'コーヒーマシン静止素材。230×230 サイズとアンカーを維持してください。',
|
||
'coffee-machine-shadow-v1': 'コーヒーマシン影レイヤー。本体と幅・アンカーを揃えると接地感が出ます。',
|
||
'serverroom-spritesheet': 'サーバールームアニメ素材。180×251 分割を維持し、過度な点滅は避けてください。',
|
||
'flowers-bloom-v2': '花ランダム素材。128×128 分割を維持し、全体の色調と合わせると馴染みます。',
|
||
'guest_anim_1': '訪客アニメセット 1(32×32 分割)。ピクセル感と輪郭太さを既存キャラに合わせてください。',
|
||
'guest_anim_2': '訪客アニメセット 2(32×32 分割)。ピクセル感と輪郭太さを既存キャラに合わせてください。',
|
||
'guest_anim_3': '訪客アニメセット 3(32×32 分割)。ピクセル感と輪郭太さを既存キャラに合わせてください。',
|
||
'guest_anim_4': '訪客アニメセット 4(32×32 分割)。ピクセル感と輪郭太さを既存キャラに合わせてください。',
|
||
'guest_anim_5': '訪客アニメセット 5(32×32 分割)。ピクセル感と輪郭太さを既存キャラに合わせてください。',
|
||
'guest_anim_6': '訪客アニメセット 6(32×32 分割)。ピクセル感と輪郭太さを既存キャラに合わせてください。',
|
||
'guest_role_1': '訪客静止フォールバック画像 1。対応する guest_anim とデザインを揃えると切替時に自然です。',
|
||
'guest_role_2': '訪客静止フォールバック画像 2。対応する guest_anim とデザインを揃えると切替時に自然です。',
|
||
'guest_role_3': '訪客静止フォールバック画像 3。対応する guest_anim とデザインを揃えると切替時に自然です。',
|
||
'guest_role_4': '訪客静止フォールバック画像 4。対応する guest_anim とデザインを揃えると切替時に自然です。',
|
||
'guest_role_5': '訪客静止フォールバック画像 5。対応する guest_anim とデザインを揃えると切替時に自然です。',
|
||
'guest_role_6': '訪客静止フォールバック画像 6。対応する guest_anim とデザインを揃えると切替時に自然です。'
|
||
}
|
||
};
|
||
|
||
function getAssetHelpText(path) {
|
||
const stem = toAssetStem(path);
|
||
const lang = (uiLang || 'zh');
|
||
const map = ASSET_HELP_TEXT_MAP[lang] || ASSET_HELP_TEXT_MAP.zh;
|
||
return map[stem] || t('assetHintDefault');
|
||
}
|
||
|
||
function renderSelectedAssetGuidance(path, inScene = null) {
|
||
const out = document.getElementById('asset-upload-result');
|
||
if (!out) return;
|
||
if (!path) { out.innerHTML = ''; return; }
|
||
const displayName = getAssetDisplayName(path);
|
||
const line1 = `📌 ${displayName}(${path})`;
|
||
const line2 = `💡 ${getAssetHelpText(path)}`;
|
||
const line3 = (inScene === false) ? `⚠️ ${t('assetHintNotInScene')}` : '';
|
||
out.innerHTML = [line1, line2, line3]
|
||
.filter(Boolean)
|
||
.map(v => `<p class="hint-p">${v}</p>`)
|
||
.join('');
|
||
}
|
||
|
||
function pathToTextureCandidates(path) {
|
||
const file = (path || '').split('/').pop() || '';
|
||
const stem = file.replace(/\.[^.]+$/, '');
|
||
const map = {
|
||
'office_bg_small': 'office_bg',
|
||
'star-idle-v5': 'star_idle',
|
||
'sofa-idle-v3': 'sofa_idle',
|
||
'sofa-shadow-v1': 'sofa_shadow',
|
||
'plants-spritesheet': 'plants',
|
||
'posters-spritesheet': 'posters',
|
||
'coffee-machine-v3-grid': 'coffee_machine',
|
||
'coffee-machine-shadow-v1': 'coffee_machine_shadow',
|
||
'serverroom-spritesheet': 'serverroom',
|
||
'error-bug-spritesheet-grid': 'error_bug',
|
||
'cats-spritesheet': 'cats',
|
||
'desk-v3': 'desk_v2',
|
||
'desk': 'desk',
|
||
'star-working-spritesheet-grid': 'star_working',
|
||
'sync-animation-v3-grid': 'sync_anim',
|
||
'memo-bg': 'memo_bg',
|
||
'flowers-bloom-v2': 'flowers',
|
||
};
|
||
const cands = [];
|
||
if (map[stem]) cands.push(map[stem]);
|
||
cands.push(stem.replace(/-/g, '_'));
|
||
cands.push(stem);
|
||
return [...new Set(cands)];
|
||
}
|
||
|
||
function getCurrentScene() {
|
||
if (!game) return null;
|
||
if (game.children && game.add) return game;
|
||
if (game.scene && game.scene.scenes && game.scene.scenes.length) return game.scene.scenes[0];
|
||
return null;
|
||
}
|
||
|
||
function getSceneChildren() {
|
||
const scene = getCurrentScene();
|
||
return (scene && scene.children && scene.children.list) ? scene.children.list : [];
|
||
}
|
||
|
||
function resolveAssetPathByTextureKey(key) {
|
||
if (!key) return null;
|
||
const keyToStem = {
|
||
office_bg: 'office_bg_small',
|
||
star_idle: 'star-idle-v5',
|
||
sofa_idle: 'sofa-idle-v3',
|
||
sofa_shadow: 'sofa-shadow-v1',
|
||
plants: 'plants-spritesheet',
|
||
posters: 'posters-spritesheet',
|
||
coffee_machine: 'coffee-machine-v3-grid',
|
||
coffee_machine_shadow: 'coffee-machine-shadow-v1',
|
||
serverroom: 'serverroom-spritesheet',
|
||
error_bug: 'error-bug-spritesheet-grid',
|
||
cats: 'cats-spritesheet',
|
||
desk_v2: 'desk-v3',
|
||
desk: 'desk',
|
||
star_working: 'star-working-spritesheet-grid',
|
||
sync_anim: 'sync-animation-v3-grid',
|
||
memo_bg: 'memo-bg',
|
||
flowers: 'flowers-bloom-v2',
|
||
};
|
||
const stem = keyToStem[key] || key.replace(/_/g, '-');
|
||
const cands = assetListData.filter(it => (it.path || '').includes(stem + '.'));
|
||
const extPriority = ['.webp', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.avif'];
|
||
for (const ext of extPriority) {
|
||
const hit = cands.find(it => (it.path || '').endsWith(ext));
|
||
if (hit) return hit.path;
|
||
}
|
||
return cands[0]?.path || null;
|
||
}
|
||
|
||
function buildSceneAssetItems() {
|
||
const children = getSceneChildren();
|
||
const byKey = new Map();
|
||
for (const ch of children) {
|
||
const key = ch && ch.texture && ch.texture.key;
|
||
if (!key) continue;
|
||
if (!byKey.has(key)) byKey.set(key, ch);
|
||
}
|
||
const items = [];
|
||
for (const [key, ref] of byKey.entries()) {
|
||
const path = resolveAssetPathByTextureKey(key);
|
||
if (!path) continue;
|
||
const meta = assetListData.find(x => x.path === path) || {};
|
||
items.push({ id: `k:${key}`, key, path, ref, ext: meta.ext || '', size: meta.size || 0, width: meta.width || null, height: meta.height || null });
|
||
}
|
||
sceneAssetItems = items.sort((a, b) => a.key.localeCompare(b.key));
|
||
}
|
||
|
||
function mapAssetPathToSprite(path) {
|
||
// 背景做特殊映射:即使纹理 key 已变成 office_bg_live_xxx,也能稳定定位到背景对象
|
||
if ((path || '').includes('office_bg_small.webp') && officeBgSprite) return officeBgSprite;
|
||
|
||
const item = sceneAssetItems.find(x => x.path === path && x.ref && x.ref.getBounds);
|
||
if (item) return item.ref;
|
||
const cands = pathToTextureCandidates(path);
|
||
const children = getSceneChildren();
|
||
for (const ch of children) {
|
||
const key = ch && ch.texture && ch.texture.key;
|
||
if (key && cands.includes(key)) return ch;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function highlightSpriteByAssetPath(path) {
|
||
const hl = document.getElementById('asset-highlight');
|
||
if (!hl || !game || !game.canvas) return false;
|
||
const sp = mapAssetPathToSprite(path);
|
||
if (!sp || !sp.getBounds) {
|
||
hl.style.display = 'none';
|
||
return false;
|
||
}
|
||
const b = sp.getBounds();
|
||
const canvasRect = game.canvas.getBoundingClientRect();
|
||
const scaleX = canvasRect.width / config.width;
|
||
const scaleY = canvasRect.height / config.height;
|
||
hl.style.display = 'block';
|
||
hl.style.left = (canvasRect.left + b.x * scaleX) + 'px';
|
||
hl.style.top = (canvasRect.top + b.y * scaleY) + 'px';
|
||
hl.style.width = Math.max(24, b.width * scaleX) + 'px';
|
||
hl.style.height = Math.max(24, b.height * scaleY) + 'px';
|
||
return true;
|
||
}
|
||
|
||
function drawSelectionBoxOnScene(path) {
|
||
const scene = getCurrentScene();
|
||
if (!scene) return false;
|
||
const sp = mapAssetPathToSprite(path);
|
||
if (!sp || !sp.getBounds) {
|
||
if (selectionBoxGraphics) selectionBoxGraphics.setVisible(false);
|
||
return false;
|
||
}
|
||
if (!selectionBoxGraphics) selectionBoxGraphics = scene.add.graphics();
|
||
const b = sp.getBounds();
|
||
selectionBoxGraphics.clear();
|
||
selectionBoxGraphics.lineStyle(4, 0x22c55e, 1);
|
||
selectionBoxGraphics.strokeRect(b.x, b.y, b.width, b.height);
|
||
selectionBoxGraphics.setDepth(999999);
|
||
selectionBoxGraphics.setVisible(true);
|
||
return true;
|
||
}
|
||
|
||
|
||
function getLiveFrameSizeByAssetPath(path) {
|
||
try {
|
||
const sprite = mapAssetPathToSprite(path);
|
||
if (sprite && sprite.frame) {
|
||
const w = Number(sprite.frame.width || 0);
|
||
const h = Number(sprite.frame.height || 0);
|
||
if (w > 0 && h > 0) return { w, h };
|
||
}
|
||
} catch (e) {}
|
||
return null;
|
||
}
|
||
|
||
function saveAssetPositionOverrides() { /* deprecated: backend only */ }
|
||
|
||
async function applySavedPositionOverrides() {
|
||
try {
|
||
// 优先:后端持久化坐标;回退:后端默认坐标;最后:本地内存覆盖
|
||
let serverPositions = {};
|
||
let serverDefaults = {};
|
||
try {
|
||
const res = await fetch('/assets/positions?t=' + Date.now(), { cache: 'no-store' });
|
||
const data = await res.json();
|
||
if (data && data.ok && data.items) serverPositions = data.items;
|
||
} catch (e) {}
|
||
try {
|
||
const res2 = await fetch('/assets/defaults?t=' + Date.now(), { cache: 'no-store' });
|
||
const data2 = await res2.json();
|
||
if (data2 && data2.ok && data2.items) serverDefaults = data2.items;
|
||
} catch (e) {}
|
||
|
||
const children = getSceneChildren();
|
||
for (const ch of children) {
|
||
const texKey = ch?.texture?.key;
|
||
if (!texKey) continue;
|
||
|
||
// 先尝试资产路径命中(推荐持久化键,优先级最高)
|
||
const assetPath = resolveAssetPathByTextureKey(texKey);
|
||
let ov = null;
|
||
if (assetPath) {
|
||
ov = serverPositions[assetPath] || serverDefaults[assetPath] || assetPositionOverrides[assetPath];
|
||
}
|
||
|
||
// 再尝试 textureKey 命中(兼容旧数据)
|
||
if (!ov) {
|
||
ov = serverPositions[texKey] || serverDefaults[texKey] || assetPositionOverrides[texKey];
|
||
}
|
||
|
||
// 最后按 stem 模糊匹配(处理 webp/png 或 live key 差异)
|
||
if (!ov) {
|
||
const stem = toAssetStem(assetPath || texKey);
|
||
const hitKey = Object.keys(serverPositions).find(k => toAssetStem(k) === stem)
|
||
|| Object.keys(serverDefaults).find(k => toAssetStem(k) === stem)
|
||
|| Object.keys(assetPositionOverrides).find(k => toAssetStem(k) === stem);
|
||
if (hitKey) ov = serverPositions[hitKey] || serverDefaults[hitKey] || assetPositionOverrides[hitKey];
|
||
}
|
||
|
||
if (!ov) continue;
|
||
const x = Number(ov.x), y = Number(ov.y), sc = Number(ov.scale || 1);
|
||
if (Number.isFinite(x) && Number.isFinite(y)) {
|
||
ch.x = x;
|
||
ch.y = y;
|
||
if (Number.isFinite(sc) && sc > 0 && ch.setScale) ch.setScale(sc);
|
||
}
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
|
||
function clearAssetSelectionUI() {
|
||
const hl = document.getElementById('asset-highlight');
|
||
if (hl) hl.style.display = 'none';
|
||
if (selectionBoxGraphics) selectionBoxGraphics.setVisible(false);
|
||
}
|
||
|
||
function clearAssetSelection(resetInputs = true) {
|
||
selectedAssetInfo = null;
|
||
updateActiveAssetItem('');
|
||
clearAssetSelectionUI();
|
||
const out = document.getElementById('asset-upload-result');
|
||
if (out) out.textContent = '';
|
||
|
||
updateAssetConfirmButtonState();
|
||
}
|
||
|
||
function applyScenePreview(path) {
|
||
const ok = highlightSpriteByAssetPath(path);
|
||
const ok2 = drawSelectionBoxOnScene(path);
|
||
return !!(ok && ok2);
|
||
}
|
||
|
||
function updateActiveAssetItem(path) {
|
||
document.querySelectorAll('#asset-list .asset-item').forEach(el => {
|
||
const p = el.getAttribute('data-path');
|
||
el.classList.toggle('active', p === path);
|
||
});
|
||
}
|
||
|
||
function updateAssetConfirmButtonState() {
|
||
const btn = document.getElementById('asset-commit-refresh-btn');
|
||
const btnReset = document.getElementById('asset-reset-default-btn');
|
||
const btnPrev = document.getElementById('asset-restore-prev-btn');
|
||
const panel = document.getElementById('asset-upload-panel');
|
||
const can = !!(selectedAssetInfo && selectedAssetInfo.path);
|
||
if (panel) panel.classList.toggle('active', can);
|
||
[btn, btnReset, btnPrev].forEach((b) => {
|
||
if (!b) return;
|
||
b.disabled = !can;
|
||
b.style.opacity = can ? '1' : '.55';
|
||
});
|
||
}
|
||
|
||
function selectAssetInDrawer(path) {
|
||
// 二次点击同一资产 = 取消选择
|
||
if (selectedAssetInfo && selectedAssetInfo.path === path) {
|
||
clearAssetSelection(true);
|
||
return;
|
||
}
|
||
selectedAssetInfo = assetListData.find(x => x.path === path) || null;
|
||
updateActiveAssetItem(path);
|
||
const ok = applyScenePreview(path);
|
||
renderSelectedAssetGuidance(path, ok);
|
||
updateAssetConfirmButtonState();
|
||
}
|
||
|
||
function clearAssetThumbTimers() {
|
||
assetThumbTimers.forEach(t => clearInterval(t));
|
||
assetThumbTimers = [];
|
||
}
|
||
|
||
function inferSpritesheetFrameMetaByPath(path) {
|
||
const p = (path || '').toLowerCase();
|
||
if (!p) return null;
|
||
// 优先用文件命名约定推断(不写死具体尺寸)
|
||
if (p.includes('spritesheet') || p.includes('sprite-sheet') || p.includes('sheet') || p.includes('anim') || p.includes('grid')) {
|
||
return { w: null, h: null };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function getSpritesheetFrameMeta(item) {
|
||
// 先看命名是否属于精灵表
|
||
const inferred = inferSpritesheetFrameMetaByPath(item?.path || '');
|
||
if (!inferred) return null;
|
||
// 仅返回“是精灵表”的信号,单帧尺寸后续自动推断
|
||
return { w: null, h: null, isSheet: true };
|
||
}
|
||
|
||
function guessThumbFrameSize(fullW, fullH, path = '') {
|
||
const p = (path || '').toLowerCase();
|
||
// 常见核心资产优先用显式提示(避免误判)
|
||
const hints = [
|
||
[/star-working-spritesheet-grid\.webp$/, 300, 300],
|
||
[/star-idle-v5\.(webp|png)$/, 256, 256],
|
||
[/sync-animation-v3-grid\.webp$/, 256, 256],
|
||
[/error-bug-spritesheet-grid\.webp$/, 220, 220],
|
||
[/flowers-bloom-v2\.webp$/, 128, 128],
|
||
[/plants-spritesheet\.webp$/, 160, 160]
|
||
];
|
||
for (const [re, fw, fh] of hints) {
|
||
if (re.test(p) && fullW % fw === 0 && fullH % fh === 0) return { fw, fh };
|
||
}
|
||
|
||
// 通用推断:枚举可整除候选,偏好 cols≈8、帧尺寸适中、近似方形
|
||
const divisors = (n) => {
|
||
const arr = [];
|
||
for (let i = 1; i * i <= n; i++) {
|
||
if (n % i === 0) {
|
||
arr.push(i);
|
||
if (i * i !== n) arr.push(n / i);
|
||
}
|
||
}
|
||
return arr.sort((a, b) => a - b);
|
||
};
|
||
const fwCand = divisors(fullW).filter(v => v >= 48 && v <= 512);
|
||
const fhCand = divisors(fullH).filter(v => v >= 48 && v <= 512);
|
||
let best = null;
|
||
for (const fw of fwCand) {
|
||
for (const fh of fhCand) {
|
||
const cols = fullW / fw;
|
||
const rows = fullH / fh;
|
||
if (!Number.isInteger(cols) || !Number.isInteger(rows)) continue;
|
||
const frames = cols * rows;
|
||
if (frames <= 1 || cols < 2 || rows < 1) continue;
|
||
let score = 0;
|
||
if (cols === 8) score += 120;
|
||
else if (cols >= 4 && cols <= 10) score += 45;
|
||
if (rows >= 1 && rows <= 10) score += 25;
|
||
score += Math.min(frames, 120) * 0.8;
|
||
score -= Math.abs(fw - fh) * 0.12;
|
||
if (fw === fullW || fh === fullH) score -= 80;
|
||
if (!best || score > best.score) best = { fw, fh, score };
|
||
}
|
||
}
|
||
return best ? { fw: best.fw, fh: best.fh } : null;
|
||
}
|
||
|
||
function tryAnimateAssetThumb(item) {
|
||
if (!item) return;
|
||
const canvas = document.getElementById(`asset-thumb-canvas-${(item.path || '').replace(/[^a-zA-Z0-9]/g, '_')}`);
|
||
if (!canvas) return;
|
||
const ctx = canvas.getContext('2d');
|
||
if (!ctx) return;
|
||
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
const fullW = img.naturalWidth || img.width;
|
||
const fullH = img.naturalHeight || img.height;
|
||
const meta = getSpritesheetFrameMeta(item);
|
||
if (!meta) return;
|
||
const guessed = guessThumbFrameSize(fullW, fullH, item?.path || '');
|
||
if (!guessed) return;
|
||
const fw = guessed.fw;
|
||
const fh = guessed.fh;
|
||
|
||
// 判断是否可能是精灵表:整图宽高至少是单帧的整数倍,且总帧数>1
|
||
const cols = Math.floor(fullW / fw);
|
||
const rows = Math.floor(fullH / fh);
|
||
const frames = cols * rows;
|
||
if (cols < 1 || rows < 1 || frames <= 1) return;
|
||
|
||
let idx = 0;
|
||
const draw = () => {
|
||
const cx = (idx % cols) * fw;
|
||
const cy = Math.floor(idx / cols) * fh;
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
ctx.imageSmoothingEnabled = false;
|
||
ctx.drawImage(img, cx, cy, fw, fh, 0, 0, canvas.width, canvas.height);
|
||
idx = (idx + 1) % frames;
|
||
};
|
||
draw();
|
||
const timer = setInterval(draw, 120);
|
||
assetThumbTimers.push(timer);
|
||
};
|
||
img.src = `/static/${item.path}?t=${Date.now()}`;
|
||
}
|
||
|
||
function isAssetHidden(path) {
|
||
return hiddenAssetPaths.has(path || '');
|
||
}
|
||
|
||
function setAssetVisible(path, visible) {
|
||
const p = (path || '').trim();
|
||
if (!p) return;
|
||
if (visible) hiddenAssetPaths.delete(p);
|
||
else hiddenAssetPaths.add(p);
|
||
|
||
const sp = mapAssetPathToSprite(p);
|
||
if (sp && sp.setVisible) {
|
||
sp.setVisible(!!visible);
|
||
}
|
||
}
|
||
|
||
function toggleAssetVisibility(path, ev) {
|
||
if (ev && ev.stopPropagation) ev.stopPropagation();
|
||
const p = (path || '').trim();
|
||
if (!p) return;
|
||
const nextVisible = isAssetHidden(p);
|
||
setAssetVisible(p, nextVisible);
|
||
renderAssetDrawerList();
|
||
const out = document.getElementById('asset-upload-result');
|
||
if (out) out.textContent = nextVisible ? `✅ 已显示:${p}` : `🙈 已隐藏:${p}`;
|
||
if (selectedAssetInfo && selectedAssetInfo.path === p) {
|
||
if (!nextVisible) clearAssetSelectionUI();
|
||
else applyScenePreview(p);
|
||
}
|
||
}
|
||
|
||
function renderAssetDrawerList() {
|
||
const q = (document.getElementById('asset-search')?.value || '').trim().toLowerCase();
|
||
const list = document.getElementById('asset-list');
|
||
if (!list) return;
|
||
|
||
// 统一显示后端全部资产(不再区分已加载/全部)
|
||
const baseRows = assetListData.map(it => ({ ...it, key: '' }));
|
||
|
||
const statePriority = [
|
||
'star-idle-v5.png',
|
||
'star-working-spritesheet-grid.webp',
|
||
'sync-animation-v3-grid.webp',
|
||
'error-bug-spritesheet-grid.webp'
|
||
];
|
||
const assetRank = (path='') => {
|
||
const p = (path || '').toLowerCase();
|
||
const idx = statePriority.findIndex(x => p.endsWith(x));
|
||
if (idx >= 0) return idx; // 0~3: 四个主状态最前
|
||
|
||
// 按钮素材最不重要:统一沉到列表末尾
|
||
if (p.includes('/btn-') || p.includes('btn-') || p.includes('button')) return 1000;
|
||
|
||
if (p.includes('guest_anim_')) return 999; // guest 动画靠后
|
||
return 100;
|
||
};
|
||
const rows = baseRows
|
||
.filter(it => !q || (it.path || '').toLowerCase().includes(q) || (it.key || '').toLowerCase().includes(q))
|
||
.sort((a,b)=> {
|
||
const ra = assetRank(a.path), rb = assetRank(b.path);
|
||
if (ra !== rb) return ra - rb;
|
||
return (a.path || '').localeCompare(b.path || '');
|
||
});
|
||
|
||
clearAssetThumbTimers();
|
||
|
||
if (rows.length === 0) {
|
||
list.innerHTML = '<div class="asset-sub" style="padding:8px">暂无资产(可点“刷新”重试)</div>';
|
||
return;
|
||
}
|
||
|
||
list.innerHTML = rows.map(it => {
|
||
const isActive = ((selectedAssetInfo && selectedAssetInfo.path) ? selectedAssetInfo.path : '') === it.path;
|
||
const reso = (it.width && it.height) ? `${it.width}×${it.height}` : '-';
|
||
const displayName = getAssetDisplayName(it.path || '');
|
||
const thumbId = `asset-thumb-canvas-${(it.path || '').replace(/[^a-zA-Z0-9]/g, '_')}`;
|
||
const hidden = isAssetHidden(it.path);
|
||
const visEmoji = hidden ? '🙈' : '👀';
|
||
return `<div class="asset-item ${isActive ? 'active' : ''}" data-path="${it.path}" onclick="selectAssetInDrawer('${(it.path || '').replace(/'/g, "\\'")}')">
|
||
<canvas id="${thumbId}" class="asset-thumb" width="56" height="56"></canvas>
|
||
<div class="asset-meta">
|
||
<div class="asset-path">${it.path}</div>
|
||
<div class="asset-sub">${displayName} | ${reso}${hidden ? ' | 已隐藏' : ''}</div>
|
||
</div>
|
||
<button class="asset-vis-btn" onclick="toggleAssetVisibility('${(it.path || '').replace(/'/g, "\\'")}', event)">${visEmoji}</button>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
// 先画静态缩略图,再尝试对精灵表做逐帧预览
|
||
rows.forEach(it => {
|
||
const canvas = document.getElementById(`asset-thumb-canvas-${(it.path || '').replace(/[^a-zA-Z0-9]/g, '_')}`);
|
||
if (!canvas) return;
|
||
const ctx = canvas.getContext('2d');
|
||
if (!ctx) return;
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
ctx.imageSmoothingEnabled = false;
|
||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||
tryAnimateAssetThumb(it);
|
||
};
|
||
img.src = `/static/${it.path}?t=${Date.now()}`;
|
||
});
|
||
}
|
||
|
||
async function refreshAssetDrawerList() {
|
||
const out = document.getElementById('asset-upload-result');
|
||
try {
|
||
const selectedPath = (selectedAssetInfo && selectedAssetInfo.path) ? selectedAssetInfo.path : '';
|
||
const res = await fetch('/assets/list');
|
||
const data = await res.json();
|
||
assetListData = data.items || [];
|
||
|
||
// 场景渲染可能稍晚,做一次延迟抓取
|
||
buildSceneAssetItems();
|
||
if (sceneAssetItems.length === 0) {
|
||
setTimeout(() => {
|
||
buildSceneAssetItems();
|
||
renderAssetDrawerList();
|
||
}, 500);
|
||
}
|
||
|
||
renderAssetDrawerList();
|
||
if (out) out.textContent = `已加载资产:${assetListData.length} | 场景抓取:${sceneAssetItems.length}`;
|
||
|
||
if (selectedPath) {
|
||
updateActiveAssetItem(selectedPath);
|
||
applyScenePreview(selectedPath);
|
||
}
|
||
} catch (e) {
|
||
console.error('加载资产列表失败', e);
|
||
if (out) out.textContent = '❌ 资产加载失败,请点“刷新”重试';
|
||
}
|
||
}
|
||
|
||
function bindDrawerFileMeta() {
|
||
const input = document.getElementById('asset-upload-file');
|
||
const out = document.getElementById('asset-upload-result');
|
||
if (!input || !out) return;
|
||
input.onchange = () => {
|
||
const f = input.files && input.files[0];
|
||
const targetPath = (selectedAssetInfo && selectedAssetInfo.path) ? selectedAssetInfo.path : '';
|
||
if (!f) {
|
||
if (targetPath) {
|
||
const inScene = !!applyScenePreview(targetPath);
|
||
renderSelectedAssetGuidance(targetPath, inScene);
|
||
} else {
|
||
out.textContent = '';
|
||
}
|
||
updateAssetConfirmButtonState();
|
||
return;
|
||
}
|
||
const targetLabel = targetPath || '-';
|
||
const pending = `${t('uploadPending')}:${f.name} | ${formatSizeHuman(f.size)} | ${t('uploadTarget')}:${targetLabel}`;
|
||
if (targetPath) {
|
||
const inScene = !!mapAssetPathToSprite(targetPath);
|
||
const displayName = getAssetDisplayName(targetPath);
|
||
const hint = getAssetHelpText(targetPath);
|
||
const warn = inScene ? '' : `⚠️ ${t('assetHintNotInScene')}`;
|
||
out.innerHTML = [
|
||
`<p class="hint-p">${pending}</p>`,
|
||
`<p class="hint-p">📌 ${displayName}(${targetPath})</p>`,
|
||
`<p class="hint-p">💡 ${hint}</p>`,
|
||
warn ? `<p class="hint-p">${warn}</p>` : ''
|
||
].filter(Boolean).join('');
|
||
} else {
|
||
out.innerHTML = `<p class="hint-p">${pending}</p>`;
|
||
}
|
||
updateAssetConfirmButtonState();
|
||
};
|
||
updateAssetConfirmButtonState();
|
||
}
|
||
|
||
let assetDrawerBackgroundBinded = false;
|
||
function bindAssetDrawerBackgroundDeselect() {
|
||
if (assetDrawerBackgroundBinded) return;
|
||
assetDrawerBackgroundBinded = true;
|
||
const body = document.getElementById('asset-drawer-body');
|
||
if (!body) return;
|
||
body.addEventListener('click', (e) => {
|
||
if (!assetDrawerOpen || !assetDrawerAuthed) return;
|
||
// 点击空白处才取消选择;点击控件/资产项不取消
|
||
const keep = e.target.closest('.asset-item, .asset-toolbar, #asset-upload-panel, #asset-move-panel, button, input, textarea, label, canvas');
|
||
if (keep) return;
|
||
clearAssetSelection(true);
|
||
});
|
||
}
|
||
|
||
function openInlineAssetUploader() {
|
||
const input = document.getElementById('asset-upload-file');
|
||
if (!input) return;
|
||
input.click();
|
||
}
|
||
async function refreshSceneObjectByAssetPath(path) {
|
||
const scene = getCurrentScene();
|
||
if (!scene || !path) return false;
|
||
|
||
const sprite = mapAssetPathToSprite(path);
|
||
if (!sprite || !sprite.texture) return false;
|
||
|
||
const oldKey = sprite.texture.key;
|
||
const ext = path.split('.').pop();
|
||
const newKey = `${oldKey}_live_${Date.now()}`;
|
||
const url = `/static/${path}?t=${Date.now()}`;
|
||
|
||
return new Promise((resolve) => {
|
||
try {
|
||
scene.load.once('complete', () => {
|
||
try {
|
||
// 替换到新纹理
|
||
if (sprite.setTexture) sprite.setTexture(newKey);
|
||
// 同 key 角色(如多个同材质装饰)一起替换
|
||
getSceneChildren().forEach(ch => {
|
||
if (ch !== sprite && ch.texture && ch.texture.key === oldKey && ch.setTexture) {
|
||
ch.setTexture(newKey);
|
||
}
|
||
});
|
||
// 更新背景引用
|
||
if (oldKey === 'office_bg' && officeBgSprite && officeBgSprite.texture && officeBgSprite.texture.key === newKey) {
|
||
currentOfficeBgTextureKey = newKey;
|
||
}
|
||
// 移除旧纹理,避免内存堆积
|
||
if (oldKey !== newKey && scene.textures.exists(oldKey)) {
|
||
scene.textures.remove(oldKey);
|
||
}
|
||
resolve(true);
|
||
} catch (e) {
|
||
console.warn('替换场景纹理失败(setTexture):', e);
|
||
resolve(false);
|
||
}
|
||
});
|
||
scene.load.once('loaderror', () => resolve(false));
|
||
|
||
// 按扩展名用对应 loader
|
||
if (ext === 'json') {
|
||
resolve(false);
|
||
return;
|
||
}
|
||
scene.load.image(newKey, url);
|
||
scene.load.start();
|
||
} catch (e) {
|
||
console.warn('替换场景纹理失败(load):', e);
|
||
resolve(false);
|
||
}
|
||
});
|
||
}
|
||
|
||
async function commitAssetUpdate() {
|
||
const path = (selectedAssetInfo && selectedAssetInfo.path) ? selectedAssetInfo.path : '';
|
||
const fi = document.getElementById('asset-upload-file');
|
||
const out = document.getElementById('asset-upload-result');
|
||
if (!path) { out.textContent = '请先选中一个资产路径'; return false; }
|
||
if (!fi.files.length) { return true; } // 允许仅改坐标
|
||
const file = fi.files[0];
|
||
const fd = new FormData();
|
||
fd.append('path', path);
|
||
fd.append('backup', '1');
|
||
fd.append('file', file);
|
||
|
||
const nameLower = (file.name || '').toLowerCase();
|
||
const isAnimInput = nameLower.endsWith('.gif') || nameLower.endsWith('.webp');
|
||
const isSheetTarget = !!inferSpritesheetFrameMetaByPath(path);
|
||
|
||
if (isSheetTarget) {
|
||
fd.append('auto_spritesheet', '1');
|
||
// 全自动:后端识别并切帧
|
||
if (isAnimInput) {
|
||
fd.append('preserve_original', '1');
|
||
} else {
|
||
// 静态图兜底切法
|
||
fd.append('frame_w', '64');
|
||
fd.append('frame_h', '64');
|
||
fd.append('preserve_original', '0');
|
||
}
|
||
fd.append('pixel_art', '1');
|
||
}
|
||
|
||
out.textContent = '⏳ 正在上传并替换,请稍候...';
|
||
const res = await fetch('/assets/upload', { method: 'POST', body: fd });
|
||
const data = await res.json();
|
||
if (!data.ok) {
|
||
out.textContent = `❌ 更新失败:${data.msg || res.status}`;
|
||
return false;
|
||
}
|
||
|
||
if (data.converted) {
|
||
const toType = data.converted.to || 'spritesheet';
|
||
out.textContent = `✅ 已上传(动图→${toType}):${data.path} | ${data.converted.frames}帧 ${data.converted.frame_w}x${data.converted.frame_h}`;
|
||
} else {
|
||
out.textContent = `✅ 已上传:${data.path}`;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
async function commitAndRefresh() {
|
||
const out = document.getElementById('asset-upload-result');
|
||
const fi = document.getElementById('asset-upload-file');
|
||
const hasFile = !!(fi && fi.files && fi.files.length > 0);
|
||
|
||
const okUpload = await commitAssetUpdate();
|
||
if (!okUpload) return;
|
||
|
||
if (out) {
|
||
if (hasFile) out.textContent += ' | ✅ 已上传并刷新';
|
||
else out.textContent = '✅ 已确认并刷新';
|
||
}
|
||
|
||
// 刷新前关闭侧边栏,行为与地图替换一致
|
||
assetDrawerOpen = false;
|
||
const drawer = document.getElementById('asset-drawer');
|
||
if (drawer) drawer.classList.remove('open');
|
||
|
||
setTimeout(() => window.location.reload(), 400);
|
||
}
|
||
|
||
function toggleBrokerPanel() {
|
||
const btn = document.querySelector('#asset-broker-row .btn-broker');
|
||
flashButtonActive(btn);
|
||
const p = document.getElementById('asset-broker-panel');
|
||
if (!p) return;
|
||
p.classList.toggle('open');
|
||
}
|
||
|
||
function toggleManualPanel() {
|
||
const btn = document.querySelector('#asset-broker-row .btn-diy');
|
||
flashButtonActive(btn);
|
||
assetManualPanelOpen = !assetManualPanelOpen;
|
||
updateManualPanelUI();
|
||
}
|
||
|
||
function placeOverlayAndStatusAtCanvasBottomLeft() {
|
||
const canvasEl = game?.canvas || document.querySelector('#game-container canvas');
|
||
const fallbackBox = document.getElementById('game-container');
|
||
const rect = canvasEl?.getBoundingClientRect?.() || fallbackBox?.getBoundingClientRect?.();
|
||
|
||
// 1) loading 遮罩
|
||
const overlay = document.getElementById('room-loading-overlay');
|
||
if (overlay) {
|
||
if (!rect || !(rect.width > 0 && rect.height > 0)) {
|
||
overlay.style.left = '0px';
|
||
overlay.style.top = '0px';
|
||
overlay.style.width = window.innerWidth + 'px';
|
||
overlay.style.height = window.innerHeight + 'px';
|
||
} else {
|
||
overlay.style.left = rect.left + 'px';
|
||
overlay.style.top = rect.top + 'px';
|
||
overlay.style.width = rect.width + 'px';
|
||
overlay.style.height = rect.height + 'px';
|
||
}
|
||
}
|
||
|
||
// 2) detail/status 严格限制在画布内部左下角
|
||
const st = document.getElementById('status-text');
|
||
const gameContainer = document.getElementById('game-container');
|
||
if (st && gameContainer) {
|
||
if (rect && rect.width > 0 && rect.height > 0) {
|
||
const localLeft = Math.max(8, Math.round(rect.left - gameContainer.getBoundingClientRect().left + 14));
|
||
const localBottom = 14;
|
||
st.style.left = localLeft + 'px';
|
||
st.style.bottom = localBottom + 'px';
|
||
st.style.maxWidth = Math.max(120, Math.floor(rect.width - 28)) + 'px';
|
||
} else {
|
||
st.style.left = '14px';
|
||
st.style.bottom = '14px';
|
||
st.style.maxWidth = 'calc(100% - 28px)';
|
||
}
|
||
}
|
||
}
|
||
|
||
function showRoomLoadingOverlay(baseText) {
|
||
const overlay = document.getElementById('room-loading-overlay');
|
||
const textEl = document.getElementById('room-loading-text');
|
||
const emojiEl = document.getElementById('room-loading-emoji');
|
||
if (!overlay || !textEl || !emojiEl) return;
|
||
|
||
placeOverlayAndStatusAtCanvasBottomLeft();
|
||
const loadingTexts = {
|
||
zh: [
|
||
'正在打包今天的灵感行李……',
|
||
'正在抽取下一站数字坐标……',
|
||
'正在查看本次漂流目的地……',
|
||
'正在把办公室折叠成随身模式……',
|
||
'正在给钳子装上远行 Buff……',
|
||
'正在匹配下一段创作气候……',
|
||
'正在把时差调成冒险模式……',
|
||
'正在接收陌生街区的 Wi‑Fi 心跳……',
|
||
'正在试播下一站的海风 BGM……',
|
||
'正在加载“也许会爱上”的新房间……',
|
||
'正在为未知邻居准备自我介绍……',
|
||
'正在解锁下一片数字海域……',
|
||
'正在把好奇心调到满格……',
|
||
'正在等待旅程投递下一张门牌号……'
|
||
],
|
||
en: [
|
||
'Packing today’s luggage of inspiration…',
|
||
'Drawing the digital coordinates for the next stop…',
|
||
'Checking the destination of this drift…',
|
||
'Folding the office into portable mode…',
|
||
'Installing a travel buff on the claws…',
|
||
'Matching the creative climate for the next chapter…',
|
||
'Switching the time zone to adventure mode…',
|
||
'Receiving Wi‑Fi heartbeats from an unfamiliar block…',
|
||
'Previewing the sea-breeze BGM of the next stop…',
|
||
'Loading a new room you might just fall in love with…',
|
||
'Preparing an intro for unknown neighbors…',
|
||
'Unlocking the next digital sea…',
|
||
'Turning curiosity up to max…',
|
||
'Waiting for the journey to deliver the next door number…'
|
||
],
|
||
ja: [
|
||
'今日のひらめき荷物を梱包しています……',
|
||
'次の目的地のデジタル座標を抽出しています……',
|
||
'今回の漂流先を確認しています……',
|
||
'オフィスを携帯モードに折りたたんでいます……',
|
||
'ハサミに遠征 Buff を装着しています……',
|
||
'次の創作区間の気候をマッチングしています……',
|
||
'時差を冒険モードに切り替えています……',
|
||
'見知らぬ街区の Wi‑Fi ハートビートを受信しています……',
|
||
'次の目的地の潮風 BGM を試聴しています……',
|
||
'「好きになるかもしれない」新しい部屋を読み込んでいます……',
|
||
'未知のご近所さん向けに自己紹介を準備しています……',
|
||
'次のデジタル海域をアンロックしています……',
|
||
'好奇心を最大値まで上げています……',
|
||
'旅が次の番地を届けるのを待っています……'
|
||
]
|
||
};
|
||
const steps = loadingTexts[uiLang] || loadingTexts.zh;
|
||
const emojis = ['🦞','🦀','🦐','🦑','🐙','🐟','🐠','🐡','🦪','🍣','🍤','🍱','🍲','🍜','🍝','🌊','🐚','🪸'];
|
||
|
||
roomLoadingIndex = 0;
|
||
roomLoadingEmojiIndex = 0;
|
||
textEl.textContent = baseText || steps[0];
|
||
emojiEl.textContent = emojis[0];
|
||
overlay.style.display = 'flex';
|
||
if (roomLoadingTimer) clearInterval(roomLoadingTimer);
|
||
roomLoadingTimer = setInterval(() => {
|
||
roomLoadingIndex = (roomLoadingIndex + 1) % steps.length;
|
||
roomLoadingEmojiIndex = (roomLoadingEmojiIndex + 1) % emojis.length;
|
||
textEl.textContent = steps[roomLoadingIndex];
|
||
emojiEl.textContent = emojis[roomLoadingEmojiIndex];
|
||
}, 900);
|
||
}
|
||
|
||
function hideRoomLoadingOverlay() {
|
||
const overlay = document.getElementById('room-loading-overlay');
|
||
if (roomLoadingTimer) {
|
||
clearInterval(roomLoadingTimer);
|
||
roomLoadingTimer = null;
|
||
}
|
||
if (overlay) overlay.style.display = 'none';
|
||
}
|
||
|
||
async function refreshOfficeBackgroundOnly() {
|
||
return await refreshSceneObjectByAssetPath('office_bg_small.webp');
|
||
}
|
||
|
||
function markMoveSuccess(outEl, btnEl = null) {
|
||
if (outEl) outEl.textContent = t('moveSuccess');
|
||
if (btnEl) setButtonDone(btnEl);
|
||
try { setState('idle', t('moveSuccess').replace('✅ ', '')); } catch (e) {}
|
||
}
|
||
|
||
function setWorkingStatus(detail = '工作中') {
|
||
try { setState('writing', detail); } catch (e) {}
|
||
}
|
||
|
||
async function ensureGeminiConfigLoaded() {
|
||
try {
|
||
const authRes = await fetch('/assets/auth/status', { cache: 'no-store' });
|
||
const authData = await authRes.json();
|
||
assetDrawerAuthed = !!(authData && authData.ok && authData.authed);
|
||
updateAssetAuthUI();
|
||
if (!assetDrawerAuthed) return;
|
||
|
||
const res = await fetch('/config/gemini', { cache: 'no-store' });
|
||
const data = await res.json();
|
||
if (data && data.ok) {
|
||
window.geminiConfig = {
|
||
hasKey: !!data.has_api_key,
|
||
model: data.gemini_model || 'nanobanana-pro'
|
||
};
|
||
const box = document.getElementById('asset-gemini-config');
|
||
if (box) box.style.display = 'block';
|
||
const ms = document.getElementById('gemini-mask-status');
|
||
if (ms) {
|
||
ms.textContent = data.has_api_key
|
||
? `${t('geminiMaskHasKey')} ${data.api_key_masked || ''}`
|
||
: t('geminiMaskNoKey');
|
||
}
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
|
||
async function saveGeminiConfigFromUI() {
|
||
const input = document.getElementById('gemini-api-key-input');
|
||
const msg = document.getElementById('gemini-config-msg');
|
||
const key = (input?.value || '').trim();
|
||
if (!key) {
|
||
if (msg) msg.textContent = '请输入有效 API Key';
|
||
return;
|
||
}
|
||
try {
|
||
const res = await fetch('/config/gemini', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ api_key: key, model: 'nanobanana-pro' })
|
||
});
|
||
const data = await res.json();
|
||
if (!data.ok) {
|
||
if (msg) msg.textContent = `保存失败:${data.msg || res.status}`;
|
||
return;
|
||
}
|
||
if (msg) msg.textContent = '✅ 已保存,可重新点击搬家/中介';
|
||
const box = document.getElementById('asset-gemini-config');
|
||
if (box) box.style.display = 'none';
|
||
await ensureGeminiConfigLoaded();
|
||
} catch (e) {
|
||
if (msg) msg.textContent = `保存失败:${e}`;
|
||
}
|
||
}
|
||
|
||
function flashButtonActive(el, ms = 180) {
|
||
if (!el) return;
|
||
el.classList.add('is-active');
|
||
setTimeout(() => el.classList.remove('is-active'), ms);
|
||
}
|
||
|
||
function setButtonDone(el, holdMs = 1200) {
|
||
if (!el) return;
|
||
el.classList.remove('is-active');
|
||
el.classList.add('is-done');
|
||
setTimeout(() => el.classList.remove('is-done'), holdMs);
|
||
}
|
||
|
||
async function generateCustomRpgBackground() {
|
||
const brokerBtn = document.querySelector('#asset-broker-row .btn-broker');
|
||
flashButtonActive(brokerBtn);
|
||
setWorkingStatus('正在处理中介装修方案');
|
||
const out = document.getElementById('asset-move-result') || document.getElementById('asset-upload-result');
|
||
const prompt = (document.getElementById('asset-broker-prompt')?.value || '').trim();
|
||
if (!prompt) {
|
||
out.textContent = t('brokerNeedPrompt');
|
||
return;
|
||
}
|
||
// 点击即刻显示遮罩,先于任何网络调用
|
||
showRoomLoadingOverlay();
|
||
out.textContent = t('brokerGenerating');
|
||
try {
|
||
const res = await fetch('/assets/generate-rpg-background', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ prompt, speed_mode: speedMode })
|
||
});
|
||
const data = await res.json();
|
||
if (!data.ok) {
|
||
if (data.code === 'MISSING_API_KEY') {
|
||
out.textContent = t('brokerMissingKey');
|
||
const box = document.getElementById('asset-gemini-config');
|
||
if (box) box.style.display = 'block';
|
||
} else if (data.code === 'API_KEY_REVOKED_OR_LEAKED') {
|
||
out.textContent = '❌ 当前 API Key 已失效/疑似泄露,请更换新 Key 后重试';
|
||
const box = document.getElementById('asset-gemini-config');
|
||
if (box) box.style.display = 'block';
|
||
} else if (data.code === 'MODEL_NOT_AVAILABLE') {
|
||
out.textContent = '❌ 当前模型在此通道不可用,请切换可用模型后重试';
|
||
} else {
|
||
out.textContent = `❌ 生成失败:${data.msg || res.status}`;
|
||
}
|
||
return;
|
||
}
|
||
out.textContent = t('brokerDone');
|
||
const ok = await refreshOfficeBackgroundOnly();
|
||
if (ok) {
|
||
markMoveSuccess(out, brokerBtn);
|
||
} else {
|
||
out.textContent = '✅ 已生成并替换底图(局部刷新失败,可手动刷新页面)';
|
||
}
|
||
} catch (e) {
|
||
out.textContent = `❌ 生成失败:${e}`;
|
||
} finally {
|
||
hideRoomLoadingOverlay();
|
||
}
|
||
}
|
||
|
||
async function generateRpgBackground() {
|
||
const moveBtn = document.getElementById('btn-move-house');
|
||
flashButtonActive(moveBtn);
|
||
setWorkingStatus('正在搬新家');
|
||
const out = document.getElementById('asset-move-result') || document.getElementById('asset-upload-result');
|
||
// 点击即刻显示遮罩,先于任何网络调用
|
||
showRoomLoadingOverlay();
|
||
out.textContent = '🧳 正在打包行李,请稍后(约30~60秒)';
|
||
try {
|
||
const res = await fetch('/assets/generate-rpg-background', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ speed_mode: speedMode })
|
||
});
|
||
const data = await res.json();
|
||
if (!data.ok) {
|
||
if (data.code === 'MISSING_API_KEY') {
|
||
out.textContent = t('brokerMissingKey');
|
||
const box = document.getElementById('asset-gemini-config');
|
||
if (box) box.style.display = 'block';
|
||
} else if (data.code === 'API_KEY_REVOKED_OR_LEAKED') {
|
||
out.textContent = '❌ 当前 API Key 已失效/疑似泄露,请更换新 Key 后重试';
|
||
const box = document.getElementById('asset-gemini-config');
|
||
if (box) box.style.display = 'block';
|
||
} else if (data.code === 'MODEL_NOT_AVAILABLE') {
|
||
out.textContent = '❌ 当前模型在此通道不可用,请切换可用模型后重试';
|
||
} else {
|
||
out.textContent = `❌ 生成失败:${data.msg || res.status}`;
|
||
}
|
||
return;
|
||
}
|
||
out.textContent = '✅ 已生成并替换底图,正在刷新房间...';
|
||
const ok = await refreshOfficeBackgroundOnly();
|
||
if (ok) {
|
||
markMoveSuccess(out, moveBtn);
|
||
} else {
|
||
out.textContent = '✅ 已生成并替换底图(局部刷新失败,可手动刷新页面)';
|
||
}
|
||
} catch (e) {
|
||
out.textContent = `❌ 生成失败:${e}`;
|
||
} finally {
|
||
hideRoomLoadingOverlay();
|
||
}
|
||
}
|
||
|
||
async function restoreHomeBackground() {
|
||
const homeBtn = document.getElementById('btn-back-home');
|
||
flashButtonActive(homeBtn);
|
||
const out = document.getElementById('asset-move-result') || document.getElementById('asset-upload-result');
|
||
|
||
const confirmMsg = '⚠️ 回老家会覆盖当前自定义房间背景(可从 bg-history 恢复历史图)。\n确定继续吗?';
|
||
if (!window.confirm(confirmMsg)) {
|
||
out.textContent = '已取消回老家';
|
||
return;
|
||
}
|
||
|
||
setWorkingStatus('正在回老家');
|
||
// 点击即刻显示遮罩,先于任何网络调用
|
||
showRoomLoadingOverlay();
|
||
out.textContent = '🏡 正在回老家(恢复初始底图)...';
|
||
try {
|
||
const res = await fetch('/assets/restore-reference-background', { method: 'POST' });
|
||
const data = await res.json();
|
||
if (!data.ok) {
|
||
out.textContent = `❌ 恢复失败:${data.msg || res.status}`;
|
||
return;
|
||
}
|
||
out.textContent = '✅ 已恢复初始底图';
|
||
const ok = await refreshOfficeBackgroundOnly();
|
||
if (ok) {
|
||
markMoveSuccess(out, homeBtn);
|
||
} else {
|
||
out.textContent = '✅ 已恢复初始底图(局部刷新失败,可手动刷新页面)';
|
||
}
|
||
} catch (e) {
|
||
out.textContent = `❌ 恢复失败:${e}`;
|
||
} finally {
|
||
hideRoomLoadingOverlay();
|
||
}
|
||
}
|
||
|
||
async function restoreLastGeneratedBackground() {
|
||
const btn = document.getElementById('btn-back-last-bg');
|
||
flashButtonActive(btn);
|
||
const out = document.getElementById('asset-move-result') || document.getElementById('asset-upload-result');
|
||
|
||
const confirmMsg = '⚠️ 将回退到最近一次生成的房间背景,确定继续吗?';
|
||
if (!window.confirm(confirmMsg)) {
|
||
out.textContent = '已取消回退';
|
||
return;
|
||
}
|
||
|
||
setWorkingStatus('正在回退到上一次背景');
|
||
showRoomLoadingOverlay();
|
||
out.textContent = '↩️ 正在回退到最近一次生成底图...';
|
||
try {
|
||
const res = await fetch('/assets/restore-last-generated-background', { method: 'POST' });
|
||
const data = await res.json();
|
||
if (!data.ok) {
|
||
out.textContent = `❌ 回退失败:${data.msg || res.status}`;
|
||
return;
|
||
}
|
||
const ok = await refreshOfficeBackgroundOnly();
|
||
if (ok) {
|
||
out.textContent = '✅ 已回退到上一次背景';
|
||
} else {
|
||
out.textContent = '✅ 已回退到上一次背景(局部刷新失败,可手动刷新页面)';
|
||
}
|
||
try { setState('idle', '已回退到上一次背景'); } catch (e) {}
|
||
} catch (e) {
|
||
out.textContent = `❌ 回退失败:${e}`;
|
||
} finally {
|
||
hideRoomLoadingOverlay();
|
||
}
|
||
}
|
||
|
||
async function fetchJsonSafe(url, options = {}) {
|
||
const res = await fetch(url, options);
|
||
const ct = (res.headers.get('content-type') || '').toLowerCase();
|
||
if (!ct.includes('application/json')) {
|
||
const txt = await res.text();
|
||
const brief = (txt || '').replace(/\s+/g, ' ').slice(0, 120);
|
||
throw new Error(`接口未返回 JSON(${res.status}): ${brief || 'empty response'}`);
|
||
}
|
||
return await res.json();
|
||
}
|
||
|
||
async function renderHomeFavorites(force = false) {
|
||
const box = document.getElementById('asset-home-favorites-list');
|
||
if (!box) return;
|
||
const now = Date.now();
|
||
if (!force && homeFavoritesCache.length > 0 && (now - homeFavoritesLoadedAt) < 30000) {
|
||
// 使用缓存,避免频繁请求
|
||
} else {
|
||
try {
|
||
const data = await fetchJsonSafe('/assets/home-favorites/list', { cache: 'no-store' });
|
||
if (data && data.ok && Array.isArray(data.items)) {
|
||
homeFavoritesCache = data.items;
|
||
homeFavoritesLoadedAt = now;
|
||
}
|
||
} catch (e) {
|
||
const out = document.getElementById('asset-move-result') || document.getElementById('asset-upload-result');
|
||
if (out) out.textContent = `❌ 收藏列表加载失败:${e.message || e}`;
|
||
}
|
||
}
|
||
|
||
if (!homeFavoritesCache.length) {
|
||
box.innerHTML = `<div class="asset-sub" style="padding:4px 2px;">${t('homeFavEmpty')}</div>`;
|
||
return;
|
||
}
|
||
|
||
box.innerHTML = homeFavoritesCache.map((it) => {
|
||
const id = (it.id || '').replace(/'/g, "\\'");
|
||
const thumb = it.thumb_url || it.url || '';
|
||
const time = it.created_at || '';
|
||
return `<div class="home-fav-item">
|
||
<img src="${thumb}" loading="lazy" alt="favorite-home" />
|
||
<div class="home-fav-meta">${time}</div>
|
||
<button onclick="applyHomeFavorite('${id}')">${t('homeFavApply')}</button>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
async function saveCurrentHomeFavorite() {
|
||
const btn = document.getElementById('btn-favorite-home');
|
||
flashButtonActive(btn);
|
||
const out = document.getElementById('asset-move-result') || document.getElementById('asset-upload-result');
|
||
try {
|
||
const data = await fetchJsonSafe('/assets/home-favorites/save-current', { method: 'POST' });
|
||
if (!data.ok) {
|
||
out.textContent = `❌ 收藏失败:${data.msg || 'unknown error'}`;
|
||
return;
|
||
}
|
||
out.textContent = t('homeFavSaved');
|
||
await renderHomeFavorites(true);
|
||
} catch (e) {
|
||
out.textContent = `❌ 收藏失败:${e.message || e}`;
|
||
}
|
||
}
|
||
|
||
async function applyHomeFavorite(id) {
|
||
const out = document.getElementById('asset-move-result') || document.getElementById('asset-upload-result');
|
||
if (!id) return;
|
||
showRoomLoadingOverlay();
|
||
setWorkingStatus('正在替换收藏地图');
|
||
try {
|
||
const data = await fetchJsonSafe('/assets/home-favorites/apply', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id })
|
||
});
|
||
if (!data.ok) {
|
||
out.textContent = `❌ 替换失败:${data.msg || 'unknown error'}`;
|
||
return;
|
||
}
|
||
const ok = await refreshOfficeBackgroundOnly();
|
||
out.textContent = ok ? t('homeFavApplied') : `${t('homeFavApplied')}(局部刷新失败,可手动刷新页面)`;
|
||
try { setState('idle', '已应用收藏地图'); } catch (e) {}
|
||
} catch (e) {
|
||
out.textContent = `❌ 替换失败:${e.message || e}`;
|
||
} finally {
|
||
hideRoomLoadingOverlay();
|
||
}
|
||
}
|
||
|
||
async function resetSelectedAssetToDefault() {
|
||
const out = document.getElementById('asset-upload-result');
|
||
const path = selectedAssetInfo && selectedAssetInfo.path;
|
||
if (!path) {
|
||
if (out) out.textContent = '请先选择一个资产';
|
||
return;
|
||
}
|
||
if (!window.confirm(`⚠️ 确定将 ${path} 重置为默认资产吗?`)) return;
|
||
try {
|
||
const res = await fetch('/assets/restore-default', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ path })
|
||
});
|
||
const data = await res.json();
|
||
if (!data.ok) {
|
||
if (out) out.textContent = `❌ 重置失败:${data.msg || res.status}`;
|
||
return;
|
||
}
|
||
await refreshSceneObjectByAssetPath(path);
|
||
if (out) out.textContent = `✅ 已重置为默认资产:${path}`;
|
||
} catch (e) {
|
||
if (out) out.textContent = `❌ 重置失败:${e}`;
|
||
}
|
||
}
|
||
|
||
async function restoreSelectedAssetPrev() {
|
||
const out = document.getElementById('asset-upload-result');
|
||
const path = selectedAssetInfo && selectedAssetInfo.path;
|
||
if (!path) {
|
||
if (out) out.textContent = '请先选择一个资产';
|
||
return;
|
||
}
|
||
if (!window.confirm(`⚠️ 确定将 ${path} 回退到上一版吗?`)) return;
|
||
try {
|
||
const res = await fetch('/assets/restore-prev', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ path })
|
||
});
|
||
const data = await res.json();
|
||
if (!data.ok) {
|
||
if (out) out.textContent = `❌ 回退失败:${data.msg || res.status}`;
|
||
return;
|
||
}
|
||
await refreshSceneObjectByAssetPath(path);
|
||
if (out) out.textContent = `✅ 已回退到上一版:${path}`;
|
||
} catch (e) {
|
||
if (out) out.textContent = `❌ 回退失败:${e}`;
|
||
}
|
||
}
|
||
|
||
async function toggleAssetDrawer(force) {
|
||
const drawer = document.getElementById('asset-drawer');
|
||
const next = (typeof force === 'boolean') ? force : !assetDrawerOpen;
|
||
assetDrawerOpen = next;
|
||
drawer.classList.toggle('open', next);
|
||
document.body.classList.toggle('drawer-open', next);
|
||
|
||
const openBtn = document.getElementById('btn-open-drawer');
|
||
if (openBtn) {
|
||
openBtn.classList.toggle('is-active', next);
|
||
openBtn.textContent = t('btnDecor');
|
||
}
|
||
const closeBtn = document.getElementById('btn-close-drawer');
|
||
if (closeBtn) closeBtn.textContent = t('drawerClose');
|
||
if (next) {
|
||
assetManualPanelOpen = false;
|
||
updateAssetAuthUI();
|
||
bindAssetDrawerBackgroundDeselect();
|
||
await ensureGeminiConfigLoaded();
|
||
if (assetDrawerAuthed) {
|
||
await refreshAssetDrawerList();
|
||
await renderHomeFavorites(false);
|
||
bindDrawerFileMeta();
|
||
} else {
|
||
const msg = document.getElementById('asset-auth-msg');
|
||
if (msg) msg.textContent = t('authDefaultPassHint');
|
||
}
|
||
} else {
|
||
assetManualPanelOpen = false;
|
||
updateManualPanelUI();
|
||
clearAssetSelectionUI();
|
||
}
|
||
}
|
||
|
||
// 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 || isDemoMercury) {
|
||
// 统一使用动态像素角色,避免依赖已删除的 demo 静态图
|
||
const animKey = 'guest_anim_1';
|
||
const f = 0;
|
||
sprite = game.add.sprite(p.x, p.y, animKey, f).setOrigin(0.5, 1).setScale(1.1);
|
||
if (sprite.anims && sprite.anims.play) sprite.anims.play(animKey, true);
|
||
} 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 bubbleTextMapByLang = {
|
||
zh: {
|
||
idle: '我去休息区躺一下',
|
||
writing: '我在工作中',
|
||
researching: '我在调研中',
|
||
executing: '我在执行任务',
|
||
syncing: '我在同步状态',
|
||
error: '出错了!我去报警区'
|
||
},
|
||
en: {
|
||
idle: 'Taking a break in the lounge.',
|
||
writing: 'I am working now.',
|
||
researching: 'I am researching now.',
|
||
executing: 'I am executing tasks.',
|
||
syncing: 'I am syncing status.',
|
||
error: 'Something broke! Heading to alert zone.'
|
||
},
|
||
ja: {
|
||
idle: '休憩エリアでひと休み。',
|
||
writing: '作業中です。',
|
||
researching: '調査中です。',
|
||
executing: 'タスクを実行中です。',
|
||
syncing: '状態を同期中です。',
|
||
error: 'エラー発生!アラートエリアへ。'
|
||
}
|
||
};
|
||
const bubbleTextMap = bubbleTextMapByLang[uiLang] || bubbleTextMapByLang.zh;
|
||
|
||
// 确保两位 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 lang = uiLang;
|
||
const welcomeTexts = {
|
||
zh: [`欢迎 ${newAgent.name} 来到办公室~`,`Hi ${newAgent.name},一起开工吧`,`${newAgent.name} 已加入,欢迎!`],
|
||
en: [`Welcome ${newAgent.name} to the office!`,`Hi ${newAgent.name}, let’s build something.`,`${newAgent.name} just joined — welcome!`],
|
||
ja: [`${newAgent.name} さん、オフィスへようこそ!`,`Hi ${newAgent.name}、一緒に進めよう。`,`${newAgent.name} さんが参加しました、歓迎!`]
|
||
};
|
||
const langPack = BUBBLE_TEXTS[lang] || BUBBLE_TEXTS.zh;
|
||
const oldTexts = Array.isArray(langPack.writing) ? [...langPack.writing] : [];
|
||
langPack.writing = welcomeTexts[lang] || welcomeTexts.zh;
|
||
showBubble();
|
||
// 还原
|
||
langPack.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);
|
||
applyLanguage();
|
||
updateSpeedModeUI();
|
||
|
||
// 动态探测 flowers 精灵表帧规格(避免写死 65x65 导致显示比例异常)
|
||
try {
|
||
const res = await fetch('/assets/list?t=' + Date.now(), { cache: 'no-store' });
|
||
const data = await res.json();
|
||
if (data && data.ok && Array.isArray(data.items)) {
|
||
const flowerItem = data.items.find(it => (it.path || '').toLowerCase().includes('flowers-bloom-v2'));
|
||
if (flowerItem && Number(flowerItem.width) > 0 && Number(flowerItem.height) > 0) {
|
||
const w = Number(flowerItem.width);
|
||
const h = Number(flowerItem.height);
|
||
// 固定规则:花朵单帧 128x128,4x4
|
||
FLOWERS_FRAME_W = 128;
|
||
FLOWERS_FRAME_H = 128;
|
||
FLOWERS_FRAME_COLS = 4;
|
||
FLOWERS_FRAME_ROWS = 4;
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.warn('flowers 规格探测失败,使用默认 65x65', e);
|
||
}
|
||
|
||
// 启动 Phaser 游戏
|
||
new Phaser.Game(config);
|
||
setTimeout(() => { applySavedPositionOverrides(); }, 600);
|
||
}
|
||
|
||
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.webp?v={{VERSION_TIMESTAMP}}');
|
||
this.load.spritesheet('star_idle', '/static/star-idle-v5.png?v={{VERSION_TIMESTAMP}}', { frameWidth: 256, frameHeight: 256 });
|
||
|
||
// Furniture
|
||
this.load.image('sofa_idle', '/static/sofa-idle-v3.png?v={{VERSION_TIMESTAMP}}');
|
||
this.load.image('sofa_shadow', '/static/sofa-shadow-v1.png?v={{VERSION_TIMESTAMP}}');
|
||
|
||
// Decor
|
||
this.load.spritesheet('plants', '/static/plants-spritesheet.webp?v={{VERSION_TIMESTAMP}}', { frameWidth: 160, frameHeight: 160 });
|
||
this.load.spritesheet('posters', '/static/posters-spritesheet.webp?v={{VERSION_TIMESTAMP}}', { frameWidth: 160, frameHeight: 160 });
|
||
this.load.spritesheet('coffee_machine', '/static/coffee-machine-v3-grid.webp?v={{VERSION_TIMESTAMP}}', { frameWidth: 230, frameHeight: 230 });
|
||
this.load.image('coffee_machine_shadow', '/static/coffee-machine-shadow-v1.png?v={{VERSION_TIMESTAMP}}');
|
||
this.load.spritesheet('serverroom', '/static/serverroom-spritesheet.webp?v={{VERSION_TIMESTAMP}}', { frameWidth: 180, frameHeight: 251 });
|
||
|
||
// Error / bug animation: 180x180, 96 frames (repacked grid)
|
||
this.load.spritesheet('error_bug', '/static/error-bug-spritesheet-grid.webp?v={{VERSION_TIMESTAMP}}', { frameWidth: 220, frameHeight: 220 });
|
||
|
||
// 运行时 Gemini 配置(用于搬家/中介生图)
|
||
this.geminiConfig = { hasKey: false, model: 'gemini-3.1-flash-image-preview' };
|
||
|
||
// Cat spritesheet: 160x160, 4x4=16 cats
|
||
this.load.spritesheet('cats', '/static/cats-spritesheet.webp?v={{VERSION_TIMESTAMP}}', { frameWidth: 160, frameHeight: 160 });
|
||
|
||
// Desk
|
||
// Star working animation: repacked to grid to avoid WebGL max texture size limits
|
||
// NOTE: prefer WebP for size, PNG fallback
|
||
// 动态替换后按最新素材识别:当前 writing 素材为 300x300 单帧
|
||
this.load.spritesheet('star_working', '/static/star-working-spritesheet-grid.webp?v={{VERSION_TIMESTAMP}}', { frameWidth: 300, frameHeight: 300 });
|
||
|
||
// Sync state animation (256x256, 多帧): 非同步显示首帧,同步从第2帧循环
|
||
this.load.spritesheet('sync_anim', '/static/sync-animation-v3-grid.webp?v={{VERSION_TIMESTAMP}}', { frameWidth: 256, frameHeight: 256 });
|
||
|
||
// Memo background image
|
||
// memo 底图固定走 png,避免某些端 webp 透明通道异常导致“底图丢失”
|
||
this.load.image('memo_bg', '/static/memo-bg.webp?v={{VERSION_TIMESTAMP}}');
|
||
// Desk v2 (webp only)
|
||
this.load.image('desk_v2', '/static/desk-v3.webp?v={{VERSION_TIMESTAMP}}');
|
||
// Flower spritesheet (65x65, 16 frames)
|
||
this.load.spritesheet('flowers', '/static/flowers-bloom-v2.webp?v={{VERSION_TIMESTAMP}}', { frameWidth: FLOWERS_FRAME_W, frameHeight: FLOWERS_FRAME_H });
|
||
|
||
// Guest/Demo agent sprites
|
||
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 });
|
||
}
|
||
|
||
function create() {
|
||
game = this;
|
||
officeBgSprite = this.add.image(640, 360, 'office_bg');
|
||
|
||
// Place furniture: Sofa
|
||
// NOTE: coordinates are interpreted as the TOP-LEFT corner of the sprite
|
||
const sofaShadow = this.add.image(IDLE_SOFA_ANCHOR.x, IDLE_SOFA_ANCHOR.y, 'sofa_shadow').setOrigin(0.5);
|
||
sofaShadow.setDepth(9);
|
||
sofa = this.add.sprite(IDLE_SOFA_ANCHOR.x, IDLE_SOFA_ANCHOR.y, 'sofa_idle').setOrigin(0.5);
|
||
sofa.setDepth(10);
|
||
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: IDLE_SOFA_ANCHOR.x, y: IDLE_SOFA_ANCHOR.y } // 与 sofa-idle-v3 同中心锚点
|
||
};
|
||
|
||
// 创建 Star 角色待命动画(每次先移除旧定义,确保不复用历史动画)
|
||
const starIdleFrameMax = Math.max(0, (this.textures.get('star_idle')?.frameTotal || 1) - 1);
|
||
if (this.anims.exists('star_idle')) {
|
||
this.anims.remove('star_idle');
|
||
}
|
||
this.anims.create({
|
||
key: 'star_idle',
|
||
frames: this.anims.generateFrameNumbers('star_idle', { start: 0, end: starIdleFrameMax }),
|
||
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(IDLE_STAR_SCALE);
|
||
star.setAlpha(0.95);
|
||
star.setDepth(20); // Put Star on top of everything
|
||
// Default: idle shows Star idle animation
|
||
star.setVisible(true);
|
||
star.anims.play('star_idle', true);
|
||
|
||
// Sofa stays static when idle (no longer the main idle animation)
|
||
sofa.anims.stop();
|
||
sofa.setTexture('sofa_idle');
|
||
|
||
// 加像素风小牌匾:海辛小龙虾的办公室
|
||
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, t('officeTitle'), {
|
||
fontFamily: 'ArkPixel, monospace',
|
||
fontSize: '18px',
|
||
fill: '#ffd700',
|
||
fontWeight: '900',
|
||
fontStyle: 'bold',
|
||
stroke: '#000',
|
||
strokeThickness: 3
|
||
}).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);
|
||
window.officePlaqueText = plaqueText;
|
||
|
||
// 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 = (this.textures.get('posters')?.frameTotal || 1) - 1;
|
||
const randomPosterFrame = Math.floor(Math.random() * Math.max(1, 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)
|
||
const catsFrameCount = (this.textures.get('cats')?.frameTotal || 1) - 1;
|
||
const randomCatFrame = Math.floor(Math.random() * Math.max(1, 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 (659,397) - animated sprite + shadow
|
||
const coffeeMachineShadow = this.add.image(659, 397, 'coffee_machine_shadow').setOrigin(0.5);
|
||
coffeeMachineShadow.setDepth(98);
|
||
const coffeeFrameMax = Math.max(0, (this.textures.get('coffee_machine')?.frameTotal || 1) - 2);
|
||
if (this.anims.exists('coffee_machine')) {
|
||
this.anims.remove('coffee_machine');
|
||
}
|
||
this.anims.create({
|
||
key: 'coffee_machine',
|
||
frames: this.anims.generateFrameNumbers('coffee_machine', { start: 0, end: coffeeFrameMax }),
|
||
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
|
||
const serverFrameMax = Math.max(0, (this.textures.get('serverroom')?.frameTotal || 1) - 2);
|
||
this.anims.create({
|
||
key: 'serverroom_on',
|
||
frames: this.anims.generateFrameNumbers('serverroom', { start: 0, end: serverFrameMax }),
|
||
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,390), default scale 0.8 (top layer)
|
||
const flowerFrameCount = Math.max(1, FLOWERS_FRAME_COLS * FLOWERS_FRAME_ROWS); // 动态帧数
|
||
const randomFlowerFrame = Math.floor(Math.random() * flowerFrameCount);
|
||
const flower = this.add.sprite(310, 390, 'flowers', randomFlowerFrame).setOrigin(0.5);
|
||
flower.setScale(0.8);
|
||
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',
|
||
// 38 帧(0~37),避免沿用旧 192 帧导致疯狂闪烁
|
||
frames: this.anims.generateFrameNumbers('star_working', { start: 0, end: 37 }),
|
||
frameRate: 12,
|
||
repeat: -1
|
||
});
|
||
|
||
// Error / bug animation (96 frames)
|
||
this.anims.create({
|
||
key: 'error_bug',
|
||
frames: this.anims.generateFrameNumbers('error_bug', { start: 0, end: 71 }),
|
||
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, 343, 'star_working', 0).setOrigin(0.5);
|
||
starWorking.setVisible(false);
|
||
starWorking.setScale(0.9);
|
||
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)
|
||
const syncFrameTotal = Number(this.textures.get('sync_anim')?.frameTotal || 0);
|
||
const syncFrameStart = 1;
|
||
const syncFrameEnd = Math.max(0, syncFrameTotal - 2);
|
||
// 仅在确实存在可播放帧(>=1)时才创建同步动画,避免单帧素材触发播放异常
|
||
syncAnimPlayable = syncFrameTotal >= 3 && syncFrameEnd >= syncFrameStart;
|
||
if (this.anims.exists('sync_anim')) {
|
||
this.anims.remove('sync_anim');
|
||
}
|
||
if (syncAnimPlayable) {
|
||
this.anims.create({
|
||
key: 'sync_anim',
|
||
frames: this.anims.generateFrameNumbers('sync_anim', { start: syncFrameStart, end: syncFrameEnd }),
|
||
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');
|
||
placeOverlayAndStatusAtCanvasBottomLeft();
|
||
window.addEventListener('resize', placeOverlayAndStatusAtCanvasBottomLeft);
|
||
window.addEventListener('scroll', placeOverlayAndStatusAtCanvasBottomLeft, { passive: true });
|
||
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 ? t('hideCoords') : t('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();
|
||
placeOverlayAndStatusAtCanvasBottomLeft();
|
||
});
|
||
}
|
||
|
||
camera.setBounds(0, 0, MAP_W, MAP_H);
|
||
clampCameraScroll();
|
||
|
||
function setPanEnabled(on) {
|
||
panEnabled = on;
|
||
if (panToggle) {
|
||
panToggle.dataset.on = on ? '1' : '0';
|
||
panToggle.textContent = on ? t('lockView') : t('moveView');
|
||
panToggle.style.background = on ? '#e94560' : '#333';
|
||
}
|
||
game.input.setDefaultCursor(on ? 'grab' : 'default');
|
||
if (isTouchDevice && statusText) {
|
||
const info = on ? '视野拖动已开启(可左右拖动画布)' : '视野拖动已关闭(点击左上角“移动视野”可开启)';
|
||
statusText.textContent = `状态:[${(STATES[currentState] && STATES[currentState].name) || '待命'}] ${info}`;
|
||
}
|
||
}
|
||
|
||
if (panToggle) {
|
||
panToggle.addEventListener('click', () => setPanEnabled(!panEnabled));
|
||
}
|
||
|
||
// 手机端默认关闭拖动画面:由左上角“移动视野”开关显式开启
|
||
if (isTouchDevice) {
|
||
setPanEnabled(false);
|
||
}
|
||
|
||
// 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 = 'auto';
|
||
|
||
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);
|
||
}
|
||
// 固定在原地(按需求取消 error 移动路径)
|
||
window.errorBug.x = 1007;
|
||
window.errorBug.y = 221;
|
||
} else {
|
||
window.errorBug.setVisible(false);
|
||
window.errorBug.anims.stop();
|
||
}
|
||
}
|
||
|
||
// Sync animation fallback logic
|
||
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 {
|
||
syncAnimSprite.setFrame(0);
|
||
}
|
||
} else {
|
||
if (syncAnimSprite.anims && 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() {
|
||
return fetch('/status', { cache: 'no-store' })
|
||
.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;
|
||
|
||
// Set state immediately (no waypoints/path movement)
|
||
pendingDesiredState = null;
|
||
currentState = nextState;
|
||
|
||
// Idle: show Star idle animation (main character)
|
||
if (nextState === 'idle') {
|
||
sofa.anims.stop();
|
||
sofa.setTexture('sofa_idle');
|
||
|
||
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 {
|
||
// 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;
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error('fetchStatus apply error', err);
|
||
typewriterTarget = '状态更新异常,正在恢复...';
|
||
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;
|
||
|
||
// idle 时锁定位置(不走任何移动路径)
|
||
if (effectiveState === 'idle') {
|
||
if (star && star.visible) {
|
||
star.setPosition(IDLE_SOFA_ANCHOR.x, IDLE_SOFA_ANCHOR.y);
|
||
}
|
||
isMoving = false;
|
||
return;
|
||
}
|
||
|
||
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') {
|
||
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);
|
||
sofa.anims.stop();
|
||
sofa.setTexture('sofa_idle');
|
||
} 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') {
|
||
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);
|
||
sofa.anims.stop();
|
||
sofa.setTexture('sofa_idle');
|
||
} 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 getBubbleTextsByState(stateKey) {
|
||
const langPack = BUBBLE_TEXTS[uiLang] || BUBBLE_TEXTS.zh;
|
||
return langPack[stateKey] || langPack.idle || [];
|
||
}
|
||
|
||
function showBubble() {
|
||
if (bubble) { bubble.destroy(); bubble = null; }
|
||
const texts = getBubbleTextsByState(currentState);
|
||
if (currentState === 'idle') return; // idle 不显示气泡(可按需开启)
|
||
|
||
// 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 bubbleOffsetY = (currentState === 'writing') ? 85 : 70;
|
||
const bubbleY = anchorY - bubbleOffsetY;
|
||
|
||
// 只做手机端稍微调大一点,避免发糊
|
||
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 = getBubbleTextsByState('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>
|