| <!DOCTYPE html> |
| <html lang="zh-CN"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>HuggingClaw's Home</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; |
| } |
| body.asset-window-mode { |
| background: transparent !important; |
| padding: 0 !important; |
| gap: 0 !important; |
| min-height: 0 !important; |
| height: auto !important; |
| overflow: hidden !important; |
| } |
| body.asset-window-mode #window-controls, |
| body.asset-window-mode #status-fab, |
| body.asset-window-mode #loading-overlay, |
| body.asset-window-mode #main-stage, |
| body.asset-window-mode #bottom-panels, |
| body.asset-window-mode #asset-highlight, |
| body.asset-window-mode #room-loading-overlay, |
| body.asset-window-mode #coords-overlay, |
| body.asset-window-mode #coords-toggle, |
| body.asset-window-mode #pan-toggle, |
| body.asset-window-mode #lang-toggle-group { |
| display: none !important; |
| } |
| body.asset-window-mode #asset-drawer-backdrop { |
| display: none !important; |
| } |
| body.asset-window-mode #asset-drawer { |
| position: fixed !important; |
| inset: 0 !important; |
| width: auto !important; |
| height: 100vh !important; |
| max-width: none !important; |
| border-radius: 10px !important; |
| border: 0 !important; |
| box-shadow: 0 10px 30px rgba(0,0,0,0.45) !important; |
| background: rgba(17, 24, 39, 0.96) !important; |
| transform: none !important; |
| display: flex !important; |
| opacity: 1 !important; |
| pointer-events: auto !important; |
| } |
| body.asset-window-mode #asset-drawer-body { |
| flex: 1 1 auto !important; |
| min-height: 0 !important; |
| padding-bottom: 10px !important; |
| } |
| body.asset-window-mode #asset-drawer-header { |
| cursor: move !important; |
| } |
| button { |
| user-select: none; |
| -webkit-user-select: none; |
| -moz-user-select: none; |
| -ms-user-select: none; |
| } |
| |
| body.desktop-shell { |
| background: transparent !important; |
| --desktop-game-width: 684px; |
| --desktop-game-height: 384px; |
| padding: 6px 0 8px !important; |
| gap: 8px !important; |
| justify-content: flex-start !important; |
| overflow: hidden !important; |
| } |
| body.desktop-shell #main-stage { |
| width: var(--desktop-game-width) !important; |
| } |
| body.desktop-shell #game-container { |
| width: var(--desktop-game-width) !important; |
| height: var(--desktop-game-height) !important; |
| max-width: var(--desktop-game-width) !important; |
| max-height: var(--desktop-game-height) !important; |
| min-height: var(--desktop-game-height) !important; |
| aspect-ratio: 16 / 9 !important; |
| border-radius: 8px !important; |
| overflow: hidden !important; |
| } |
| body.desktop-shell #game-container canvas { |
| box-shadow: none !important; |
| } |
| body.desktop-shell #bottom-panels { |
| width: var(--desktop-game-width) !important; |
| max-width: var(--desktop-game-width) !important; |
| flex-direction: row !important; |
| gap: 8px !important; |
| flex-wrap: nowrap !important; |
| align-items: flex-start !important; |
| margin-top: 5px !important; |
| } |
| body.desktop-shell #memo-panel { |
| flex: 1 1 0 !important; |
| width: auto !important; |
| height: 132px !important; |
| padding: 6px 8px 8px !important; |
| } |
| body.desktop-shell #control-bar { |
| flex: 1 1 0 !important; |
| width: auto !important; |
| height: 132px !important; |
| padding: 6px 8px 8px !important; |
| gap: 6px !important; |
| } |
| body.desktop-shell #guest-agent-panel { |
| flex: 1 1 0 !important; |
| width: auto !important; |
| height: 132px !important; |
| padding: 6px 8px 8px !important; |
| gap: 6px !important; |
| } |
| body.desktop-shell #control-buttons { |
| display: grid; |
| grid-template-columns: repeat(2, minmax(0, 1fr)); |
| gap: 6px !important; |
| align-items: stretch; |
| justify-items: stretch; |
| } |
| body.desktop-shell #control-buttons button { |
| height: 34px !important; |
| font-size: 11px !important; |
| padding: 4px 6px !important; |
| } |
| body.desktop-shell #control-bar-title, |
| body.desktop-shell #guest-agent-panel-title, |
| body.desktop-shell #memo-title { |
| font-size: 12px !important; |
| } |
| body.desktop-shell #guest-agent-list { |
| gap: 4px !important; |
| } |
| body.desktop-shell .guest-agent-item { |
| padding: 5px 6px !important; |
| gap: 4px !important; |
| } |
| body.desktop-shell .guest-agent-name { |
| font-size: 11px !important; |
| } |
| body.desktop-shell .guest-agent-buttons button { |
| font-size: 10px !important; |
| padding: 4px 6px !important; |
| } |
| body.desktop-shell #memo-date { |
| font-size: 9px !important; |
| left: -10px !important; |
| } |
| body.desktop-shell #memo-content { |
| font-size: 10px !important; |
| line-height: 1.55 !important; |
| left: 12px !important; |
| } |
| body.desktop-shell .panel-collapsible.collapsed { |
| height: 62px !important; |
| min-height: 62px !important; |
| } |
| body.desktop-shell .panel-collapsible:not(.collapsed) { |
| height: 220px !important; |
| min-height: 220px !important; |
| } |
| body.desktop-shell #status-text { |
| display: none !important; |
| } |
| #status-fab { |
| display: none; |
| } |
| body.desktop-shell #status-fab { |
| display: block; |
| position: fixed; |
| top: calc(env(safe-area-inset-top, 0px) + 10px); |
| left: 50%; |
| transform: translateX(-50%); |
| z-index: 1000001; |
| max-width: min(62vw, 520px); |
| padding: 6px 12px; |
| border-radius: 8px; |
| background: rgba(0, 0, 0, 0.72); |
| color: #eee; |
| font-size: 12px; |
| line-height: 1.25; |
| white-space: nowrap; |
| overflow: hidden; |
| text-overflow: ellipsis; |
| pointer-events: none; |
| } |
| body.desktop-shell #loading-overlay { |
| background: rgba(0, 0, 0, 0.18) !important; |
| backdrop-filter: blur(2px); |
| } |
| body.desktop-shell #pan-toggle, |
| body.desktop-shell #coords-toggle { |
| display: none !important; |
| } |
| #window-controls { |
| display: none; |
| } |
| body.desktop-shell #window-controls { |
| display: flex; |
| position: fixed; |
| top: calc(env(safe-area-inset-top, 0px) + 10px); |
| left: 12px; |
| z-index: 1000002; |
| gap: 8px; |
| } |
| .traffic-btn { |
| width: 12px; |
| height: 12px; |
| border-radius: 50%; |
| border: none; |
| outline: none; |
| cursor: pointer; |
| box-shadow: 0 0 0 1px rgba(0,0,0,0.25) inset; |
| position: relative; |
| transition: transform 0.12s ease, filter 0.16s ease, box-shadow 0.16s ease; |
| } |
| .traffic-btn.close { background: #ff5f57; } |
| .traffic-btn.min { background: #febc2e; } |
| .traffic-btn.max { background: #28c840; opacity: 1; cursor: pointer; } |
| .traffic-btn:disabled { pointer-events: none; } |
| .traffic-btn::before, |
| .traffic-btn::after { |
| content: ''; |
| position: absolute; |
| left: 50%; |
| top: 50%; |
| background: rgba(53, 53, 53, 0.9); |
| opacity: 0; |
| transition: opacity 0.14s ease; |
| transform-origin: center; |
| } |
| .traffic-btn.close::before, |
| .traffic-btn.close::after { |
| width: 7px; |
| height: 1.4px; |
| margin-left: -3.5px; |
| margin-top: -0.7px; |
| border-radius: 999px; |
| background: rgba(95, 24, 23, 0.95); |
| } |
| .traffic-btn.close::before { transform: rotate(45deg); } |
| .traffic-btn.close::after { transform: rotate(-45deg); } |
| .traffic-btn.min::before { |
| width: 7px; |
| height: 1.4px; |
| margin-left: -3.5px; |
| margin-top: -0.7px; |
| border-radius: 999px; |
| background: rgba(116, 83, 11, 0.95); |
| } |
| .traffic-btn.min::after { display: none; } |
| .traffic-btn.max::before { |
| width: 4px; |
| height: 4px; |
| left: 50%; |
| top: 50%; |
| margin-left: -2.3px; |
| margin-top: -2.3px; |
| background: rgba(28, 86, 38, 0.96); |
| clip-path: polygon(0 0, 100% 0, 0 100%); |
| transform: none; |
| } |
| .traffic-btn.max::after { |
| width: 4px; |
| height: 4px; |
| left: 50%; |
| top: 50%; |
| margin-left: -0.8px; |
| margin-top: -0.8px; |
| background: rgba(28, 86, 38, 0.96); |
| clip-path: polygon(100% 100%, 0 100%, 100% 0); |
| transform: none; |
| } |
| body.desktop-shell #window-controls:hover .traffic-btn::before, |
| body.desktop-shell #window-controls:hover .traffic-btn::after, |
| .traffic-btn:hover::before, |
| .traffic-btn:hover::after { |
| opacity: 1; |
| } |
| .traffic-btn:hover { |
| transform: translateY(-0.5px); |
| filter: saturate(1.03) brightness(1.02); |
| box-shadow: 0 0 0 1px rgba(0,0,0,0.28) inset, 0 0 0 0.5px rgba(255,255,255,0.18); |
| } |
| .traffic-btn:active { |
| transform: translateY(0); |
| filter: brightness(0.94); |
| } |
| body.electron-shell #window-controls { |
| display: flex !important; |
| gap: 7px; |
| top: calc(env(safe-area-inset-top, 0px) + 11px); |
| left: 13px; |
| } |
| body.electron-shell .traffic-btn { |
| width: 12px; |
| height: 12px; |
| border: 1px solid rgba(0, 0, 0, 0.28); |
| box-shadow: |
| 0 1px 0 rgba(255, 255, 255, 0.28) inset, |
| 0 0 0 1px rgba(0, 0, 0, 0.06); |
| transform: none; |
| } |
| body.electron-shell .traffic-btn.close { |
| background: radial-gradient(circle at 35% 30%, #ff8a82 0%, #ff5f57 68%); |
| } |
| body.electron-shell .traffic-btn.min { |
| background: radial-gradient(circle at 35% 30%, #ffd76a 0%, #febc2e 68%); |
| } |
| body.electron-shell .traffic-btn.max { |
| background: radial-gradient(circle at 35% 30%, #61e26f 0%, #28c840 68%); |
| } |
| body.electron-shell .traffic-btn.close::before, |
| body.electron-shell .traffic-btn.close::after { |
| background: rgba(77, 18, 17, 0.92); |
| width: 6.5px; |
| height: 1.35px; |
| margin-left: -3.25px; |
| margin-top: -0.67px; |
| } |
| body.electron-shell .traffic-btn.min::before { |
| background: rgba(96, 66, 9, 0.94); |
| width: 6.5px; |
| height: 1.35px; |
| margin-left: -3.25px; |
| margin-top: -0.67px; |
| } |
| body.electron-shell .traffic-btn.max::before { |
| background: rgba(16, 93, 31, 0.95); |
| width: 6.6px; |
| height: 1.35px; |
| margin-left: -3.3px; |
| margin-top: -0.67px; |
| clip-path: none; |
| } |
| body.electron-shell .traffic-btn.max::after { |
| background: rgba(16, 93, 31, 0.95); |
| width: 1.35px; |
| height: 6.6px; |
| margin-left: -0.67px; |
| margin-top: -3.3px; |
| clip-path: none; |
| } |
| body.electron-shell #window-controls .traffic-btn::before, |
| body.electron-shell #window-controls .traffic-btn::after { |
| opacity: 0; |
| } |
| body.electron-shell #window-controls:hover .traffic-btn::before, |
| body.electron-shell #window-controls:hover .traffic-btn::after { |
| opacity: 1; |
| } |
| body.electron-shell .traffic-btn:hover { |
| transform: none; |
| filter: saturate(1.02) brightness(1.015); |
| } |
| body.desktop-shell #lang-toggle-group { |
| top: calc(env(safe-area-inset-top, 0px) + 10px) !important; |
| left: auto !important; |
| right: 24px !important; |
| gap: 6px !important; |
| } |
| body.desktop-shell #lang-toggle-group button { |
| min-height: 28px; |
| padding: 6px 12px; |
| border-radius: 8px; |
| border: 1px solid rgba(255, 255, 255, 0.14); |
| background: rgba(0, 0, 0, 0.72); |
| color: #eee; |
| font-family: 'ArkPixel', monospace; |
| font-size: 12px; |
| line-height: 1.25; |
| } |
| body.desktop-shell #lang-toggle-group button.lang-active { |
| background: #141722; |
| color: rgb(246, 208, 6); |
| border-color: rgb(246, 208, 6); |
| box-shadow: 0 0 0 1px rgba(246, 208, 6, 0.45) inset; |
| } |
| body.desktop-shell #asset-drawer { |
| box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45) !important; |
| } |
| |
| #stage-row { |
| display: flex; |
| flex-direction: row; |
| align-items: flex-start; |
| gap: 16px; |
| max-width: 98vw; |
| width: fit-content; |
| } |
| #main-stage { |
| position: relative; |
| flex: 1 1 auto; |
| min-width: 0; |
| max-width: 1280px; |
| transition: margin-left .25s ease; |
| will-change: margin-left; |
| } |
| body.drawer-open #main-stage { |
| margin-left: 0 !important; |
| } |
| #game-container, |
| #game-container canvas { |
| max-width: 100% !important; |
| } |
| #bottom-panels { |
| display: flex; |
| gap: 20px; |
| width: 100%; |
| max-width: 100%; |
| justify-content: flex-start; |
| margin-top: 20px; |
| flex-wrap: wrap; |
| } |
| #chatlog-panel { |
| width: 300px; |
| flex-shrink: 0; |
| |
| align-self: flex-start; |
| background: #1a1d27; |
| border: 4px solid #0e1119; |
| padding: 12px 16px; |
| display: flex; |
| flex-direction: column; |
| overflow: hidden; |
| font-family: 'ArkPixel', monospace; |
| } |
| #chatlog-title { |
| font-size: 14px; |
| color: #e0c97f; |
| margin-bottom: 8px; |
| text-align: center; |
| } |
| #chatlog-content { |
| flex: 1; |
| overflow-y: auto; |
| font-size: 13px; |
| line-height: 1.6; |
| color: #d1d5db; |
| } |
| #chatlog-content .chat-msg { |
| margin-bottom: 4px; |
| padding: 2px 0; |
| } |
| #chatlog-content .chat-msg .chat-speaker { |
| font-weight: bold; |
| } |
| #chatlog-content .chat-msg .chat-speaker.adam { color: #f87171; } |
| #chatlog-content .chat-msg .chat-speaker.eve { color: #a78bfa; } |
| #chatlog-content .chat-msg .chat-speaker.god { color: #fbbf24; } |
| #chatlog-content .chat-msg .chat-time { color: #6b7280; font-size: 0.85em; } |
| #game-container { |
| position: relative; |
| border: 0; |
| image-rendering: pixelated; |
| width: 100%; |
| max-width: 1280px; |
| aspect-ratio: 16 / 9; |
| 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; |
| } |
| #office-plaque-dom { |
| position: absolute; |
| left: 50%; |
| bottom: 14px; |
| transform: translateX(-50%); |
| display: inline-flex; |
| align-items: center; |
| gap: 14px; |
| padding: 6px 12px; |
| border: 2px solid #3e2723; |
| background: #5d4037; |
| color: #ffd700; |
| font-family: 'ArkPixel', 'Courier New', monospace; |
| font-size: 13px; |
| line-height: 1; |
| text-shadow: 0 1px 0 #000, 0 0 1px #000; |
| z-index: 35; |
| pointer-events: auto; |
| white-space: nowrap; |
| cursor: text; |
| } |
| #office-plaque-text { |
| font-size: 13px; |
| line-height: 1; |
| color: #ffd700; |
| outline: none; |
| min-width: 120px; |
| text-align: center; |
| } |
| #office-plaque-text.editing { |
| padding: 1px 4px; |
| background: rgba(0, 0, 0, 0.28); |
| box-shadow: 0 0 0 1px rgba(255, 215, 0, 0.45) inset; |
| } |
| .office-plaque-star { |
| font-size: 13px; |
| line-height: 1; |
| } |
| |
| #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: 84px; |
| left: 50%; |
| right: auto; |
| width: 420px; |
| max-width: none; |
| height: 760px; |
| background: #111827; |
| border: 2px solid #22c55e; |
| border-radius: 10px; |
| box-shadow: 0 10px 30px rgba(0,0,0,0.45); |
| transform: translateX(-50%); |
| transition: opacity 0.18s ease; |
| z-index: 1000010; |
| display: none; |
| flex-direction: column; |
| opacity: 0; |
| pointer-events: none; |
| } |
| #asset-drawer.open { |
| display: flex; |
| opacity: 1; |
| pointer-events: auto; |
| } |
| #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; |
| cursor: move; |
| user-select: none; |
| border-top-left-radius: 8px; |
| border-top-right-radius: 8px; |
| } |
| #asset-drawer-body { |
| padding: 10px; |
| padding-bottom: 150px; |
| overflow: auto; |
| color: #e5e7eb; |
| font-size: 12px; |
| position: relative; |
| } |
| #asset-drawer-backdrop { |
| position: fixed; |
| inset: 0; |
| background: rgba(0, 0, 0, 0.2); |
| z-index: 1000009; |
| display: none; |
| } |
| #asset-drawer-backdrop.open { |
| display: block; |
| } |
| .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; |
| } |
| |
| #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); |
| } |
| |
| body.desktop-shell #control-bar #btn-state-idle, |
| body.desktop-shell #control-bar #btn-state-writing, |
| body.desktop-shell #control-bar #btn-state-syncing, |
| body.desktop-shell #control-bar #btn-state-error { |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| text-align: center; |
| height: 40px !important; |
| padding: 0 8px 8px !important; |
| line-height: 1 !important; |
| } |
| body.desktop-shell #control-bar #btn-state-idle:active, |
| body.desktop-shell #control-bar #btn-state-writing:active, |
| body.desktop-shell #control-bar #btn-state-syncing:active, |
| body.desktop-shell #control-bar #btn-state-error:active { |
| padding-top: 5px !important; |
| padding-bottom: 3px !important; |
| } |
| |
| #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-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-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; |
| 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; |
| 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; |
| } |
| .panel-collapsible { |
| transition: height 0.2s ease, min-height 0.2s ease; |
| } |
| .panel-collapsible.collapsed { |
| height: 48px !important; |
| min-height: 48px !important; |
| overflow: hidden; |
| } |
| .panel-collapsible.collapsed > :not(.panel-toggle-title) { |
| display: none !important; |
| } |
| body.desktop-shell .panel-collapsible { |
| border-radius: 0; |
| border: none !important; |
| background: transparent !important; |
| box-shadow: none !important; |
| } |
| body.desktop-shell .panel-toggle-title { |
| height: 44px; |
| min-height: 44px; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| background: #0B1117; |
| color: rgb(246, 208, 6) !important; |
| border-radius: 0; |
| border: 2px solid #6f84a2; |
| box-shadow: 0 2px 8px rgba(0, 0, 0, 0.22); |
| margin: 0; |
| padding: 0 12px; |
| line-height: 1; |
| font-size: 13px; |
| letter-spacing: 0.5px; |
| text-shadow: none; |
| transition: transform 0.15s ease, filter 0.15s ease, box-shadow 0.15s ease; |
| } |
| body.desktop-shell .panel-toggle-title:hover { |
| background: #0B1117; |
| box-shadow: 0 4px 12px rgba(0, 0, 0, 0.28); |
| transform: translateY(-1px); |
| } |
| body.desktop-shell .panel-toggle-title:active { |
| transform: translateY(0); |
| } |
| body.desktop-shell #memo-title, |
| body.desktop-shell #control-bar-title, |
| body.desktop-shell #guest-agent-panel-title { |
| display: flex !important; |
| align-items: center !important; |
| justify-content: center !important; |
| height: 44px !important; |
| min-height: 44px !important; |
| margin: 0 !important; |
| padding: 0 12px !important; |
| border: 2px solid #6f84a2 !important; |
| border-bottom: 2px solid #6f84a2 !important; |
| border-radius: 0 !important; |
| background: #0B1117 !important; |
| color: rgb(246, 208, 6) !important; |
| font-size: 13px !important; |
| font-weight: 400 !important; |
| line-height: 1 !important; |
| letter-spacing: 0.5px !important; |
| text-shadow: none !important; |
| box-shadow: 0 2px 8px rgba(0, 0, 0, 0.22) !important; |
| position: relative !important; |
| top: 0 !important; |
| left: 0 !important; |
| } |
| body.desktop-shell .panel-collapsible.collapsed { |
| pointer-events: auto; |
| padding: 6px 8px !important; |
| } |
| body.desktop-shell .panel-collapsible.collapsed .panel-toggle-title { |
| pointer-events: auto; |
| margin: 0; |
| } |
| body.desktop-shell #memo-panel.collapsed::before, |
| body.desktop-shell #memo-panel.collapsed::after, |
| body.desktop-shell #control-bar.collapsed::before, |
| body.desktop-shell #control-bar.collapsed::after, |
| body.desktop-shell #guest-agent-panel.collapsed::before, |
| body.desktop-shell #guest-agent-panel.collapsed::after { |
| display: none !important; |
| } |
| |
| body.desktop-shell #memo-panel:not(.collapsed)::before, |
| body.desktop-shell #memo-panel:not(.collapsed)::after { |
| display: none !important; |
| } |
| body.desktop-shell .panel-collapsible:not(.collapsed) { |
| padding: 6px 8px 8px !important; |
| } |
| body.desktop-shell #memo-panel:not(.collapsed) { |
| background-image: url('/static/memo-bg.webp?v={{VERSION_TIMESTAMP}}') !important; |
| background-size: contain !important; |
| |
| background-position: center 52px !important; |
| background-repeat: no-repeat !important; |
| min-height: 240px !important; |
| height: 240px !important; |
| } |
| body.desktop-shell #control-bar:not(.collapsed), |
| body.desktop-shell #guest-agent-panel:not(.collapsed) { |
| background: transparent !important; |
| border: none !important; |
| border-radius: 0 !important; |
| box-shadow: none !important; |
| } |
| body.desktop-shell #guest-agent-list::-webkit-scrollbar-track { |
| background: rgba(250, 244, 207, 0.65); |
| } |
| body.desktop-shell #guest-agent-list::-webkit-scrollbar-thumb { |
| background: rgba(93, 64, 55, 0.55); |
| } |
| body.desktop-shell .guest-agent-item { |
| background: rgba(250, 244, 207, 0.9) !important; |
| border: 1px solid #edd690 !important; |
| } |
| body.desktop-shell .guest-agent-name, |
| body.desktop-shell #guest-agent-panel [style*="color:#cbd5e1"] { |
| color: #5d4037 !important; |
| } |
| body.desktop-shell #control-bar button, |
| body.desktop-shell .guest-agent-buttons button { |
| border: 1px solid #edd690; |
| background: rgba(250, 244, 207, 0.92); |
| color: #5d4037 !important; |
| user-select: none; |
| -webkit-user-select: none; |
| } |
| body.desktop-shell #control-bar button:hover, |
| body.desktop-shell .guest-agent-buttons button:hover { |
| border-color: #faf4cf; |
| background: rgba(237, 214, 144, 0.96); |
| } |
| .panel-toggle-title { |
| cursor: pointer; |
| user-select: none; |
| } |
| |
| |
| |
| @media (min-width: 901px) and (max-width: 1200px) { |
| #chatlog-panel { |
| width: 240px; |
| } |
| } |
| |
| |
| @media (max-width: 900px) { |
| html, body { |
| height: 100%; |
| } |
| |
| body { |
| padding: 0; |
| gap: 0; |
| overflow-x: hidden; |
| overflow-y: auto; |
| -webkit-overflow-scrolling: touch; |
| align-items: stretch; |
| } |
| |
| #game-container { |
| width: 100vw; |
| max-width: 100vw; |
| border-width: 0; |
| border-radius: 0; |
| aspect-ratio: 16 / 9; |
| height: auto; |
| max-height: 56.25vw; |
| flex: 0 0 auto; |
| touch-action: auto; |
| overflow: hidden; |
| } |
| |
| #stage-row { |
| flex-direction: column; |
| gap: 0; |
| width: 100vw; |
| max-width: 100vw; |
| } |
| |
| #main-stage { |
| width: 100vw; |
| min-width: 0; |
| margin-left: 0 !important; |
| } |
| |
| #chatlog-panel { |
| width: 100vw; |
| height: auto; |
| max-height: 35vh; |
| border-left: 0; |
| border-right: 0; |
| border-radius: 0; |
| border-top: 2px solid #0e1119; |
| } |
| |
| #chatlog-content { |
| max-height: calc(35vh - 40px); |
| overflow-y: auto; |
| -webkit-overflow-scrolling: touch; |
| } |
| |
| #bottom-panels { |
| width: 100vw; |
| max-width: 100vw; |
| 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; |
| max-height: 84vh; |
| } |
| #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; |
| } |
| } |
| |
| |
| @media (max-width: 900px) and (max-height: 500px) { |
| #game-container { |
| max-height: 70vh; |
| } |
| #chatlog-panel { |
| max-height: 25vh; |
| } |
| #chatlog-content { |
| max-height: calc(25vh - 36px); |
| } |
| } |
| |
| |
| @media (max-width: 400px) { |
| #chatlog-panel { |
| padding: 8px 10px; |
| } |
| #chatlog-title { |
| font-size: 12px; |
| } |
| #chatlog-content { |
| font-size: 12px; |
| line-height: 1.5; |
| } |
| } |
| </style> |
| </head> |
| <body> |
| <div id="window-controls"> |
| <button id="btn-close" class="traffic-btn close" title="关闭"></button> |
| <button id="btn-minimize-mode" class="traffic-btn min" title="最小化模式"></button> |
| <button id="btn-open-frontend" class="traffic-btn max" title="回到前端界面"></button> |
| </div> |
| <div id="status-fab">加载中...</div> |
| |
| <div id="loading-overlay"> |
| <div id="loading-text">Loading HuggingClaw’s Home...</div> |
| <div id="loading-progress-container"> |
| <div id="loading-progress-bar"></div> |
| </div> |
| </div> |
| |
| <div id="stage-row"> |
| <div id="main-stage"> |
| <div id="game-container"> |
| <div id="status-text">加载中...</div> |
| <div id="office-plaque-dom"> |
| <span class="office-plaque-star">⭐</span> |
| <span id="office-plaque-text">HuggingClaw's Home</span> |
| <span class="office-plaque-star">⭐</span> |
| </div> |
| </div> |
|
|
| |
| <div id="bottom-panels"> |
| </div> |
| </div> |
|
|
| |
| <div id="chatlog-panel"> |
| <div id="chatlog-title"> |
| 🦞 Adam ↔ Eve |
| <span id="chatlog-lang-toggle" style="float:right;font-size:12px;cursor:pointer;"> |
| <span id="chatlog-lang-en" onclick="setChatLang('en')" style="opacity:1;">EN</span> |
| <span style="margin:0 4px;color:#555;">|</span> |
| <span id="chatlog-lang-zh" onclick="setChatLang('zh')" style="opacity:0.4;">中文</span> |
| </span> |
| </div> |
| <div id="chatlog-content"> |
| <div style="color:#9ca3af;font-size:12px;text-align:center;padding:20px 0;">Waiting for conversation to start...</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">Loading the office...</div> |
| </div> |
| </div> |
|
|
| <div id="asset-drawer-backdrop" onclick="toggleAssetDrawer(false)"></div> |
| <aside id="asset-drawer" data-no-window-drag="1"> |
| <div id="asset-drawer-header" data-no-window-drag="1"> |
| <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> |
| |
|
|
| <script src="/static/vendor/phaser-3.80.1.min.js"></script> |
| <script> |
| // 简易中英文切换 |
| let uiLang = localStorage.getItem('uiLang') || 'en'; |
| const OFFICE_PLAQUE_STORAGE_KEY = 'officePlaqueTitle'; |
| let officePlaqueCustomTitle = (localStorage.getItem(OFFICE_PLAQUE_STORAGE_KEY) || '').trim(); |
| 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: 'HuggingClaw 的家', |
| loadingOffice: '正在加载 HuggingClaw 的家...', |
| panelExpand: '展开', panelCollapse: '收起', |
| hiddenTag: '已隐藏', assetListLoaded: '已加载资产', sceneCaptured: '场景抓取', assetListLoadFailed: '资产加载失败,请点“刷新”重试', |
| authNeedInput: '请输入验证码', authPassOk: '验证通过', authPassWrong: '验证码错误', |
| stateDetailIdle: '待命', stateDetailWriting: '整理文档', stateDetailResearching: '搜索信息', stateDetailExecuting: '执行任务', stateDetailSyncing: '同步备份', stateDetailError: '出错了', |
| stateLabelIdle: '待命', stateLabelWriting: '整理文档', stateLabelResearching: '搜索信息', stateLabelExecuting: '执行任务', stateLabelSyncing: '同步备份', stateLabelError: '出错了', |
| statusBrokerDecorating: '正在处理中介装修方案', statusMovingHome: '正在搬新家', statusRestoreHome: '正在回老家', statusRestoreLastBg: '正在回退到上一次背景', statusApplyFavorite: '正在替换收藏地图' |
| }, |
| 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: "HuggingClaw's Home", |
| loadingOffice: "Loading HuggingClaw’s Home...", |
| panelExpand: 'Expand', panelCollapse: 'Collapse', |
| hiddenTag: 'Hidden', assetListLoaded: 'Assets loaded', sceneCaptured: 'Scene captured', assetListLoadFailed: 'Failed to load assets. Click Refresh and retry', |
| authNeedInput: 'Please enter passcode', authPassOk: 'Passcode verified', authPassWrong: 'Wrong passcode', |
| stateDetailIdle: 'Standby', stateDetailWriting: 'Organizing Docs', stateDetailResearching: 'Researching', stateDetailExecuting: 'Executing Tasks', stateDetailSyncing: 'Syncing Backup', stateDetailError: 'Error', |
| stateLabelIdle: 'Standby', stateLabelWriting: 'Organizing Docs', stateLabelResearching: 'Researching', stateLabelExecuting: 'Executing Tasks', stateLabelSyncing: 'Syncing Backup', stateLabelError: 'Error', |
| statusBrokerDecorating: 'Applying broker decoration plan', statusMovingHome: 'Moving to a new home', statusRestoreHome: 'Restoring home background', statusRestoreLastBg: 'Restoring previous generated background', statusApplyFavorite: 'Applying favorite map' |
| }, |
| 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: 'HuggingClaw の家', |
| loadingOffice: 'HuggingClaw の家を読み込み中...', |
| panelExpand: '展開', panelCollapse: '折りたたむ', |
| hiddenTag: '非表示', assetListLoaded: 'アセット読み込み', sceneCaptured: 'シーン取得', assetListLoadFailed: 'アセット読み込み失敗。更新して再試行してください', |
| authNeedInput: 'パスコードを入力してください', authPassOk: '認証に成功しました', authPassWrong: 'パスコードが正しくありません', |
| stateDetailIdle: '待機', stateDetailWriting: '文書整理', stateDetailResearching: '情報検索', stateDetailExecuting: 'タスク実行', stateDetailSyncing: '同期バックアップ', stateDetailError: 'エラー発生', |
| stateLabelIdle: '待機', stateLabelWriting: '文書整理', stateLabelResearching: '情報検索', stateLabelExecuting: 'タスク実行', stateLabelSyncing: '同期バックアップ', stateLabelError: 'エラー発生', |
| statusBrokerDecorating: '仲介の装飾プランを処理中', statusMovingHome: '引っ越し中', statusRestoreHome: 'ホーム背景を復元中', statusRestoreLastBg: '前回背景へ復元中', statusApplyFavorite: '保存したマップを適用中' |
| } |
| }; |
| |
| function t(key) { return (I18N[uiLang] && I18N[uiLang][key]) || key; } |
| function getOfficePlaqueTitle() { |
| return (window.officeNameFromServer || officePlaqueCustomTitle || t('officeTitle')); |
| } |
| function refreshOfficePlaqueTitle() { |
| const el = document.getElementById('office-plaque-text'); |
| if (!el || el.dataset.editing === '1') return; |
| el.textContent = getOfficePlaqueTitle(); |
| } |
| function saveOfficePlaqueTitle(raw) { |
| const next = (raw || '').trim(); |
| officePlaqueCustomTitle = next; |
| if (next) localStorage.setItem(OFFICE_PLAQUE_STORAGE_KEY, next); |
| else localStorage.removeItem(OFFICE_PLAQUE_STORAGE_KEY); |
| } |
| function initOfficePlaqueEditor() { |
| const plaque = document.getElementById('office-plaque-dom'); |
| const textEl = document.getElementById('office-plaque-text'); |
| if (!plaque || !textEl) return; |
| |
| const beginEdit = () => { |
| if (textEl.dataset.editing === '1') return; |
| textEl.dataset.editing = '1'; |
| textEl.contentEditable = 'true'; |
| textEl.spellcheck = false; |
| textEl.classList.add('editing'); |
| textEl.focus(); |
| const sel = window.getSelection(); |
| if (sel) { |
| const range = document.createRange(); |
| range.selectNodeContents(textEl); |
| range.collapse(false); |
| sel.removeAllRanges(); |
| sel.addRange(range); |
| } |
| }; |
| |
| const finishEdit = (shouldSave) => { |
| if (textEl.dataset.editing !== '1') return; |
| textEl.contentEditable = 'false'; |
| textEl.dataset.editing = '0'; |
| textEl.classList.remove('editing'); |
| if (shouldSave) saveOfficePlaqueTitle(textEl.textContent || ''); |
| refreshOfficePlaqueTitle(); |
| }; |
| |
| plaque.addEventListener('click', () => beginEdit()); |
| textEl.addEventListener('keydown', (e) => { |
| if (e.key === 'Enter') { |
| e.preventDefault(); |
| finishEdit(true); |
| } else if (e.key === 'Escape') { |
| e.preventDefault(); |
| textEl.textContent = getOfficePlaqueTitle(); |
| finishEdit(false); |
| } |
| }); |
| textEl.addEventListener('blur', () => finishEdit(true)); |
| refreshOfficePlaqueTitle(); |
| } |
| 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 getStateDetailByState(state) { |
| const keyMap = { |
| idle: 'stateDetailIdle', |
| writing: 'stateDetailWriting', |
| researching: 'stateDetailResearching', |
| executing: 'stateDetailExecuting', |
| syncing: 'stateDetailSyncing', |
| error: 'stateDetailError' |
| }; |
| return t(keyMap[state] || 'stateDetailIdle'); |
| } |
| |
| function getStateLabelByState(state) { |
| if (state === 'syncing') return t('stateLabelSyncing'); |
| if (state === 'error') return t('stateLabelError'); |
| if (state === 'researching') return t('stateLabelResearching'); |
| if (state === 'executing') return t('stateLabelExecuting'); |
| if (state === 'writing') return t('stateLabelWriting'); |
| return t('stateLabelIdle'); |
| } |
| |
| async function syncDesktopUiLanguage() { |
| if (!DESKTOP_MODE || !window.__TAURI__ || !window.__TAURI__.core) return; |
| try { |
| await window.__TAURI__.core.invoke('set_ui_lang', { lang: uiLang }); |
| } catch (_) {} |
| } |
| |
| 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.classList.toggle('lang-active', active); |
| }); |
| |
| 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'); |
| memoTitle.dataset.baseTitle = t('memoTitle'); |
| } |
| const controlTitle = document.getElementById('control-bar-title'); |
| if (controlTitle) controlTitle.dataset.baseTitle = t('controlTitle'); |
| const guestTitle = document.getElementById('guest-agent-panel-title'); |
| if (guestTitle) { |
| guestTitle.textContent = t('guestTitle'); |
| guestTitle.dataset.baseTitle = t('guestTitle'); |
| } |
| refreshOfficePlaqueTitle(); |
| |
| refreshCollapsiblePanelTitles(); |
| |
| 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)); |
| syncDesktopUiLanguage(); |
| } |
| |
| 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 PAGE_PARAMS = new URLSearchParams(window.location.search); |
| const ELECTRON_MODE = !!window.__ELECTRON__; |
| const DESKTOP_MODE = !!window.__TAURI__ || PAGE_PARAMS.get('desktop') === '1'; |
| const ASSET_WINDOW_MODE = PAGE_PARAMS.get('assetWindow') === '1'; |
| if (DESKTOP_MODE) { |
| document.body.classList.add('desktop-shell'); |
| } |
| if (ASSET_WINDOW_MODE) { |
| document.body.classList.add('asset-window-mode'); |
| const drawer = document.getElementById('asset-drawer'); |
| const drawerHeader = document.getElementById('asset-drawer-header'); |
| if (drawer) drawer.removeAttribute('data-no-window-drag'); |
| if (drawerHeader) drawerHeader.removeAttribute('data-no-window-drag'); |
| } |
| if (ELECTRON_MODE) { |
| document.body.classList.add('electron-shell'); |
| if (!ASSET_WINDOW_MODE) { |
| const drawer = document.getElementById('asset-drawer'); |
| const backdrop = document.getElementById('asset-drawer-backdrop'); |
| if (drawer) drawer.style.display = 'none'; |
| if (backdrop) backdrop.style.display = 'none'; |
| } |
| } |
| |
| function initDesktopWindowDrag() { |
| if (!DESKTOP_MODE || !window.__TAURI__ || !window.__TAURI__.window) return; |
| const appWindow = window.__TAURI__.window.getCurrentWindow(); |
| let dragStart = null; |
| let dragTriggered = false; |
| const DRAG_THRESHOLD = 8; |
| |
| const shouldIgnoreTarget = (target) => { |
| if (!target || !(target instanceof Element)) return false; |
| return !!target.closest('button, a, input, textarea, select, [contenteditable], [data-no-window-drag]'); |
| }; |
| |
| document.addEventListener('pointerdown', (e) => { |
| if (e.button !== 0) return; |
| if (shouldIgnoreTarget(e.target)) return; |
| dragStart = { x: e.clientX, y: e.clientY }; |
| dragTriggered = false; |
| }); |
| |
| document.addEventListener('pointermove', async (e) => { |
| if (!dragStart || dragTriggered) return; |
| const dx = e.clientX - dragStart.x; |
| const dy = e.clientY - dragStart.y; |
| const moved = Math.hypot(dx, dy); |
| if (moved < DRAG_THRESHOLD) return; |
| dragTriggered = true; |
| try { |
| await appWindow.startDragging(); |
| } catch (_) { |
| // ignore drag API errors |
| } finally { |
| dragStart = null; |
| } |
| }); |
| |
| const clearDrag = () => { |
| dragStart = null; |
| dragTriggered = false; |
| }; |
| document.addEventListener('pointerup', clearDrag); |
| document.addEventListener('pointercancel', clearDrag); |
| } |
| initDesktopWindowDrag(); |
| |
| function initWindowControls() { |
| if (!DESKTOP_MODE || !window.__TAURI__ || !window.__TAURI__.core) return; |
| const core = window.__TAURI__.core; |
| const closeBtn = document.getElementById('btn-close'); |
| const miniBtn = document.getElementById('btn-minimize-mode'); |
| const maxBtn = document.getElementById('btn-open-frontend'); |
| if (closeBtn) { |
| closeBtn.addEventListener('click', async (e) => { |
| e.preventDefault(); |
| e.stopPropagation(); |
| try { await core.invoke('close_app'); } catch (_) {} |
| }); |
| } |
| if (miniBtn) { |
| miniBtn.addEventListener('click', async (e) => { |
| e.preventDefault(); |
| e.stopPropagation(); |
| try { await core.invoke('enter_minimize_mode'); } catch (_) {} |
| }); |
| } |
| if (maxBtn) { |
| maxBtn.addEventListener('click', async (e) => { |
| e.preventDefault(); |
| e.stopPropagation(); |
| try { |
| const url = new URL('/', window.location.origin).toString(); |
| if (core && core.invoke) { |
| await core.invoke('open_external_url', { url }); |
| } else { |
| window.open(url.toString(), '_blank', 'noopener,noreferrer'); |
| } |
| } catch (_) { |
| window.open('http://127.0.0.1:19000/', '_blank', 'noopener,noreferrer'); |
| } |
| }); |
| } |
| } |
| initWindowControls(); |
| |
| function initMainWindowAssetRefreshSync() { |
| if (ASSET_WINDOW_MODE) return; |
| if (!ELECTRON_MODE || !window.__TAURI__ || !window.__TAURI__.event) return; |
| window.__TAURI__.event.listen('main-window-asset-refresh', async (evt) => { |
| const payload = (evt && evt.payload) ? evt.payload : {}; |
| const kind = String(payload.kind || 'asset'); |
| const path = String(payload.path || ''); |
| try { |
| if (kind === 'asset_action') { |
| const action = String(payload.action || ''); |
| if (action === 'preview_asset' && path) { |
| applyScenePreview(path); |
| } else if (action === 'clear_preview') { |
| clearAssetSelectionUI(); |
| } else if (action === 'set_visibility' && path) { |
| const visible = !!payload.visible; |
| setAssetVisible(path, visible); |
| if (selectedAssetInfo && selectedAssetInfo.path === path) { |
| if (visible) applyScenePreview(path); |
| else clearAssetSelectionUI(); |
| } |
| } |
| } else if (kind === 'office_bg') { |
| await refreshOfficeBackgroundOnly(); |
| } else if (path) { |
| await refreshSceneObjectByAssetPath(path); |
| } else { |
| await refreshOfficeBackgroundOnly(); |
| } |
| if (assetDrawerOpen && assetDrawerAuthed) { |
| await refreshAssetDrawerList(); |
| await renderHomeFavorites(false); |
| } |
| } catch (_) {} |
| }); |
| } |
| initMainWindowAssetRefreshSync(); |
| |
| function refreshCollapsiblePanelTitles() { |
| const defs = [ |
| { panelId: 'memo-panel', titleId: 'memo-title' }, |
| { panelId: 'control-bar', titleId: 'control-bar-title' }, |
| { panelId: 'guest-agent-panel', titleId: 'guest-agent-panel-title' } |
| ]; |
| defs.forEach(({ panelId, titleId }) => { |
| const panel = document.getElementById(panelId); |
| const title = document.getElementById(titleId); |
| if (!panel || !title) return; |
| const base = (title.textContent || '').replace(/\s*\[[^\]]+\]\s*$/, '').trim(); |
| title.dataset.baseTitle = base; |
| const collapsed = panel.classList.contains('collapsed'); |
| title.textContent = `${base} [${collapsed ? t('panelExpand') : t('panelCollapse')}]`; |
| }); |
| } |
| |
| function initCollapsiblePanels() { |
| const defs = [ |
| { panelId: 'memo-panel', titleId: 'memo-title' }, |
| { panelId: 'control-bar', titleId: 'control-bar-title' }, |
| { panelId: 'guest-agent-panel', titleId: 'guest-agent-panel-title' } |
| ]; |
| |
| defs.forEach(({ panelId, titleId }) => { |
| const panel = document.getElementById(panelId); |
| const title = document.getElementById(titleId); |
| if (!panel || !title) return; |
| |
| const baseTitle = (title.textContent || '').replace(/\s*\[[^\]]+\]\s*$/, '').trim(); |
| title.dataset.baseTitle = baseTitle; |
| title.classList.add('panel-toggle-title'); |
| panel.classList.add('panel-collapsible'); |
| |
| const updateTitle = () => { |
| const collapsed = panel.classList.contains('collapsed'); |
| title.textContent = `${title.dataset.baseTitle} [${collapsed ? t('panelExpand') : t('panelCollapse')}]`; |
| }; |
| |
| const syncElectronWindowMode = async () => { |
| // asset window must NEVER resize main window. |
| if (ASSET_WINDOW_MODE) return; |
| if (!ELECTRON_MODE || !window.__TAURI__ || !window.__TAURI__.core) return; |
| const expanded = !!document.querySelector('.panel-collapsible:not(.collapsed)'); |
| try { |
| await window.__TAURI__.core.invoke('set_main_window_mode', { expanded }); |
| } catch (_) {} |
| }; |
| |
| title.addEventListener('click', () => { |
| panel.classList.toggle('collapsed'); |
| updateTitle(); |
| queueDesktopResize(); |
| syncElectronWindowMode(); |
| setTimeout(queueDesktopResize, 260); |
| setTimeout(syncElectronWindowMode, 260); |
| }); |
| |
| panel.classList.add('collapsed'); |
| updateTitle(); |
| syncElectronWindowMode(); |
| }); |
| } |
| |
| let resizeTimer = null; |
| async function syncDesktopWindowSize() { |
| if (!DESKTOP_MODE || !window.__TAURI__ || !window.__TAURI__.core) return; |
| const expanded = !!document.querySelector('.panel-collapsible:not(.collapsed)'); |
| try { |
| await window.__TAURI__.core.invoke('set_main_window_mode', { expanded }); |
| } catch (_) {} |
| } |
| function queueDesktopResize() { |
| if (resizeTimer) clearTimeout(resizeTimer); |
| resizeTimer = setTimeout(() => { syncDesktopWindowSize(); }, 40); |
| } |
| |
| initCollapsiblePanels(); |
| queueDesktopResize(); |
| window.addEventListener('resize', () => { if (DESKTOP_MODE) queueDesktopResize(); }); |
| window.addEventListener('load', () => { if (DESKTOP_MODE) queueDesktopResize(); }); |
| if (DESKTOP_MODE && document.fonts && document.fonts.ready) { |
| document.fonts.ready.then(() => { |
| queueDesktopResize(); |
| setTimeout(queueDesktopResize, 120); |
| }).catch(() => {}); |
| } |
| |
| const IS_TOUCH_DEVICE = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0) || window.matchMedia('(pointer: coarse)').matches; |
| // Desktop-only fill zoom to crop decorative side bands and make main room occupy more width. |
| const DESKTOP_FILL_ZOOM = 1.14; |
| |
| 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'); |
| if (!memoContent) return; |
| 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 = `❌ ${t('authNeedInput')}`; |
| 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 = `✅ ${t('authPassOk')}`; |
| updateAssetAuthUI(); |
| await refreshAssetDrawerList(); |
| await renderHomeFavorites(false); |
| bindDrawerFileMeta(); |
| } else { |
| assetDrawerAuthed = false; |
| if (msg) msg.textContent = `❌ ${t('authPassWrong')}`; |
| } |
| } 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 || 'en'); |
| 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.en; |
| 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 || 'en'); |
| const map = ASSET_HELP_TEXT_MAP[lang] || ASSET_HELP_TEXT_MAP.en; |
| 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(); |
| if (ASSET_WINDOW_MODE) notifyMainWindowAssetAction('clear_preview'); |
| 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); |
| if (ASSET_WINDOW_MODE) notifyMainWindowAssetAction('preview_asset', 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); |
| if (ASSET_WINDOW_MODE) notifyMainWindowAssetAction('set_visibility', p, { visible: !!nextVisible }); |
| renderAssetDrawerList(); |
| const out = document.getElementById('asset-upload-result'); |
| if (out) out.textContent = nextVisible ? `✅ ${t('assetShow')}:${p}` : `🙈 ${t('assetHide')}:${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 ? ` | ${t('hiddenTag')}` : ''}</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?t=' + Date.now(), { cache: 'no-store' }); |
| const data = await res.json(); |
| assetListData = data.items || []; |
| |
| // 场景渲染可能稍晚,做一次延迟抓取 |
| buildSceneAssetItems(); |
| if (sceneAssetItems.length === 0) { |
| setTimeout(() => { |
| buildSceneAssetItems(); |
| renderAssetDrawerList(); |
| }, 500); |
| } |
| |
| renderAssetDrawerList(); |
| if (out) out.textContent = `${t('assetListLoaded')}:${assetListData.length} | ${t('sceneCaptured')}:${sceneAssetItems.length}`; |
| |
| if (selectedPath) { |
| updateActiveAssetItem(selectedPath); |
| applyScenePreview(selectedPath); |
| } |
| } catch (e) { |
| console.error('加载资产列表失败', e); |
| if (out) out.textContent = `❌ ${t('assetListLoadFailed')}`; |
| } |
| } |
| |
| 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); |
| }); |
| } |
| |
| let assetDrawerDragInited = false; |
| function initAssetDrawerFloatingWindow() { |
| if (assetDrawerDragInited) return; |
| if (ASSET_WINDOW_MODE) return; |
| const drawer = document.getElementById('asset-drawer'); |
| const header = document.getElementById('asset-drawer-header'); |
| if (!drawer || !header) return; |
| assetDrawerDragInited = true; |
| |
| const centerDrawer = () => { |
| const left = (window.innerWidth - drawer.offsetWidth) / 2; |
| const top = Math.max(14, (window.innerHeight - drawer.offsetHeight) / 2); |
| drawer.style.left = `${left}px`; |
| drawer.style.top = `${top}px`; |
| drawer.style.transform = 'none'; |
| }; |
| |
| let dragging = false; |
| let startX = 0; |
| let startY = 0; |
| let startLeft = 0; |
| let startTop = 0; |
| |
| header.addEventListener('pointerdown', (e) => { |
| if (e.button !== 0) return; |
| if (e.target && e.target.closest('button, input, textarea, select, a, [contenteditable]')) return; |
| dragging = true; |
| startX = e.clientX; |
| startY = e.clientY; |
| startLeft = parseFloat(drawer.style.left) || drawer.getBoundingClientRect().left; |
| startTop = parseFloat(drawer.style.top) || drawer.getBoundingClientRect().top; |
| if (typeof header.setPointerCapture === 'function') { |
| try { header.setPointerCapture(e.pointerId); } catch (_) {} |
| } |
| e.preventDefault(); |
| }); |
| |
| header.addEventListener('pointermove', (e) => { |
| if (!dragging) return; |
| const dx = e.clientX - startX; |
| const dy = e.clientY - startY; |
| drawer.style.left = `${startLeft + dx}px`; |
| drawer.style.top = `${startTop + dy}px`; |
| drawer.style.transform = 'none'; |
| }); |
| |
| const endDrag = (e) => { |
| if (!dragging) return; |
| dragging = false; |
| if (e && typeof header.releasePointerCapture === 'function') { |
| try { header.releasePointerCapture(e.pointerId); } catch (_) {} |
| } |
| }; |
| header.addEventListener('pointerup', endDrag); |
| header.addEventListener('pointercancel', endDrag); |
| |
| window.addEventListener('resize', () => { |
| if (!assetDrawerOpen) return; |
| const left = parseFloat(drawer.style.left) || drawer.getBoundingClientRect().left; |
| const top = parseFloat(drawer.style.top) || drawer.getBoundingClientRect().top; |
| drawer.style.left = `${left}px`; |
| drawer.style.top = `${top}px`; |
| drawer.style.transform = 'none'; |
| }); |
| |
| centerDrawer(); |
| } |
| |
| 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 = '⏳ Uploading and replacing, please wait...'; |
| 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 path = (selectedAssetInfo && selectedAssetInfo.path) ? selectedAssetInfo.path : ''; |
| |
| 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'); |
| const backdrop = document.getElementById('asset-drawer-backdrop'); |
| if (backdrop) backdrop.classList.remove('open'); |
| if (path) await notifyMainWindowAssetRefresh('asset_path', path); |
| |
| 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.en; |
| 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'); |
| } |
| async function notifyMainWindowAssetRefresh(kind, path = '') { |
| if (!ASSET_WINDOW_MODE || !ELECTRON_MODE || !window.__TAURI__ || !window.__TAURI__.core) return; |
| try { |
| await window.__TAURI__.core.invoke('notify_main_window_asset_refresh', { kind, path }); |
| } catch (_) {} |
| } |
| async function notifyMainWindowAssetAction(action, path = '', extra = {}) { |
| if (!ASSET_WINDOW_MODE || !ELECTRON_MODE || !window.__TAURI__ || !window.__TAURI__.core) return; |
| try { |
| await window.__TAURI__.core.invoke('notify_main_window_asset_refresh', { |
| kind: 'asset_action', |
| action, |
| path, |
| ...extra |
| }); |
| } catch (_) {} |
| } |
| |
| 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 = t('stateDetailWriting')) { |
| 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(t('statusBrokerDecorating')); |
| 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(); |
| await notifyMainWindowAssetRefresh('office_bg', 'office_bg_small.webp'); |
| 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(t('statusMovingHome')); |
| const out = document.getElementById('asset-move-result') || document.getElementById('asset-upload-result'); |
| // 点击即刻显示遮罩,先于任何网络调用 |
| showRoomLoadingOverlay(); |
| out.textContent = '🧳 Packing up, please wait (~30-60s)...'; |
| 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 = '✅ Generated and replaced background, refreshing room...'; |
| const ok = await refreshOfficeBackgroundOnly(); |
| await notifyMainWindowAssetRefresh('office_bg', 'office_bg_small.webp'); |
| 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(t('statusRestoreHome')); |
| // 点击即刻显示遮罩,先于任何网络调用 |
| showRoomLoadingOverlay(); |
| out.textContent = '🏡 Restoring original background...'; |
| 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(); |
| await notifyMainWindowAssetRefresh('office_bg', 'office_bg_small.webp'); |
| 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(t('statusRestoreLastBg')); |
| showRoomLoadingOverlay(); |
| out.textContent = '↩️ Reverting to last generated background...'; |
| 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(); |
| await notifyMainWindowAssetRefresh('office_bg', 'office_bg_small.webp'); |
| 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(t('statusApplyFavorite')); |
| 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(); |
| await notifyMainWindowAssetRefresh('office_bg', 'office_bg_small.webp'); |
| 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); |
| await notifyMainWindowAssetRefresh('asset_path', 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); |
| await notifyMainWindowAssetRefresh('asset_path', path); |
| if (out) out.textContent = `✅ 已回退到上一版:${path}`; |
| } catch (e) { |
| if (out) out.textContent = `❌ 回退失败:${e}`; |
| } |
| } |
| |
| async function openAssetWindowFromMain() { |
| if (ASSET_WINDOW_MODE) return; |
| if (!ELECTRON_MODE || !window.__TAURI__ || !window.__TAURI__.core) return; |
| try { |
| await window.__TAURI__.core.invoke('open_asset_window'); |
| } catch (_) { |
| // ignore and allow retry |
| } |
| } |
| |
| async function toggleAssetDrawer(force) { |
| const drawer = document.getElementById('asset-drawer'); |
| const backdrop = document.getElementById('asset-drawer-backdrop'); |
| |
| if (!ASSET_WINDOW_MODE && ELECTRON_MODE) { |
| // Electron main window never toggles in-page drawer anymore. |
| // Keep state closed to avoid any open/close flicker, and always use dedicated asset window. |
| assetDrawerOpen = false; |
| if (drawer) drawer.classList.remove('open'); |
| if (backdrop) backdrop.classList.remove('open'); |
| document.body.classList.remove('drawer-open'); |
| |
| if (force === false) return; |
| try { |
| if (window.__TAURI__ && window.__TAURI__.core) { |
| await window.__TAURI__.core.invoke('open_asset_window'); |
| } |
| } catch (_) {} |
| return; |
| } |
| |
| if (ASSET_WINDOW_MODE) { |
| if (force === false) { |
| try { |
| if (window.__TAURI__ && window.__TAURI__.core) { |
| await window.__TAURI__.core.invoke('close_asset_window'); |
| } else { |
| window.close(); |
| } |
| } catch (_) { |
| window.close(); |
| } |
| return; |
| } |
| |
| assetDrawerOpen = true; |
| if (drawer) drawer.classList.add('open'); |
| if (backdrop) backdrop.classList.remove('open'); |
| assetManualPanelOpen = false; |
| updateAssetAuthUI(); |
| bindAssetDrawerBackgroundDeselect(); |
| await ensureGeminiConfigLoaded(); |
| if (assetDrawerAuthed) { |
| await applySavedPositionOverrides(); |
| await refreshAssetDrawerList(); |
| await renderHomeFavorites(false); |
| bindDrawerFileMeta(); |
| } else { |
| const msg = document.getElementById('asset-auth-msg'); |
| if (msg) msg.textContent = t('authDefaultPassHint'); |
| } |
| return; |
| } |
| |
| const next = (typeof force === 'boolean') ? force : !assetDrawerOpen; |
| assetDrawerOpen = next; |
| drawer.classList.toggle('open', next); |
| if (backdrop) backdrop.classList.toggle('open', next); |
| document.body.classList.remove('drawer-open'); |
| |
| 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) { |
| initAssetDrawerFloatingWindow(); |
| assetManualPanelOpen = false; |
| updateAssetAuthUI(); |
| bindAssetDrawerBackgroundDeselect(); |
| await ensureGeminiConfigLoaded(); |
| if (assetDrawerAuthed) { |
| await applySavedPositionOverrides(); |
| 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('Request failed: ' + 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('Request failed: ' + 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('Request failed: ' + error); |
| }); |
| } |
| |
| function ensureDemoVisitors() { |
| if (!DEMO_MODE) return; |
| if (!Array.isArray(window.__demoVisitors) || window.__demoVisitors.length === 0) { |
| window.__demoVisitors = [ |
| { agentId: 'demo_nika', name: 'Nika', authStatus: 'approved', state: 'writing', bubbleText: 'Working on it', isDemo: true, updated_at: new Date().toISOString() }, |
| { agentId: 'demo_mercury', name: 'Mercury', authStatus: 'approved', state: 'idle', bubbleText: 'Taking a break', 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 底部锚点坐标来用) |
| 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) }; |
| } |
| |
| // 每个 agent 独立的漫游计时器和当前目标,避免轮询抖动 |
| const guestWanderState = {}; // agentId -> { area, targetX, targetY, lastMoveAt } |
| const WANDER_INTERVAL = 4500; // 每 4.5s 换一次目标位置 |
| |
| function getWanderPoint(agentId, area) { |
| const now = Date.now(); |
| const ws = guestWanderState[agentId]; |
| if (ws && ws.area === area && (now - ws.lastMoveAt) < WANDER_INTERVAL) { |
| return { x: ws.targetX, y: ws.targetY }; |
| } |
| const rect = getAreaRect(area); |
| const p = randomPointInRect(rect); |
| guestWanderState[agentId] = { area, targetX: p.x, targetY: p.y, lastMoveAt: now }; |
| return p; |
| } |
| |
| // 父母固定点位(cats 静态猫) |
| const PARENT_IDS = new Set(['adam', 'eve']); |
| const PARENT_POSITIONS = { |
| adam: { breakroom: { x: 511, y: 262 }, writing: { x: 190, y: 526 }, error: { x: 932, y: 275 } }, |
| eve: { breakroom: { x: 841, y: 621 }, writing: { x: 380, y: 683 }, error: { x: 1109, y: 327 } } |
| }; |
| |
| function getParentPoint(agentId, area) { |
| const pos = PARENT_POSITIONS[agentId.toLowerCase()]; |
| if (!pos) return { x: 690, y: 470 }; |
| return pos[area] || pos.breakroom; |
| } |
| |
| function renderGuestAgentsInScene() { |
| if (!game) return; |
| const visitors = getMergedVisitors(); |
| const seenIds = new Set(); |
| |
| 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')); |
| |
| // 父母(Adam/Eve)固定位置,儿子及其他 agent 漫游 |
| const isParent = PARENT_IDS.has(id.toLowerCase()); |
| const p = isParent ? getParentPoint(id, area) : getWanderPoint(id, area); |
| |
| 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 if (isParent && game.textures.exists('cats')) { |
| // 父母(Adam/Eve):使用 cats spritesheet 中的静态猫咪 |
| const KNOWN_CAT_FRAMES = { 'adam': 1, 'eve': 3 }; |
| const catFrame = KNOWN_CAT_FRAMES[id.toLowerCase()] || 0; |
| sprite = game.add.sprite(p.x, p.y, 'cats', catFrame).setOrigin(0.5, 1).setScale(0.6); |
| } else { |
| // 儿子及其他访客:使用 guest_anim 动画精灵(32×32,8 帧 idle 动画) |
| const aid = String(agent.agentId || ''); |
| let animIdx; |
| if (aid.toLowerCase() === 'cain') { |
| animIdx = 1; // Cain (first child) = red (guest_anim_1) |
| } else { |
| 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 { |
| 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) - 105); |
| const nameText = game.add.text(p.x, nameTextY, agent.name || 'Guest', { |
| 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; |
| |
| const nameYOffset = isDemo ? -80 : -105; |
| |
| if (isParent) { |
| // 父母固定位置,直接 snap |
| g.sprite.x = p.x; |
| g.sprite.y = p.y + yOffset; |
| g.nameText.x = p.x; |
| g.nameText.y = (p.y + yOffset) + nameYOffset; |
| } else { |
| // 儿子及其他:平滑漫游 |
| 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); |
| 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) + nameYOffset, duration, ease }); |
| guestTweens[id] = { move: moveTween, name: nameTween }; |
| } |
| |
| g.nameText.setText(agent.name || 'Guest'); |
| |
| // Show bubble immediately when bubbleText changes from API |
| const bubbleKey = chatLang === 'zh' ? (agent.bubbleTextZh || agent.bubbleText) : agent.bubbleText; |
| if (bubbleKey && bubbleKey !== (g._lastBubbleText || '')) { |
| g._lastBubbleText = bubbleKey; |
| if (guestBubbles[id]) { guestBubbles[id].destroy(); delete guestBubbles[id]; } |
| const bx = g.sprite.x; |
| const nameH = (g.nameText && g.nameText.height) ? g.nameText.height : 16; |
| const by = (g.nameText ? g.nameText.y : (g.sprite.y - 150)) - (nameH / 2) - 22; |
| const fontSize = IS_TOUCH_DEVICE ? 16 : 14; |
| const displayText = bubbleKey.length > 80 ? bubbleKey.slice(0, 80) + '…' : bubbleKey; |
| const maxBubbleW = 300; |
| const txtR = game.add.text(bx, by - 10, displayText, { fontFamily: 'ArkPixel, monospace', fontSize: fontSize + 'px', fill: '#000', wordWrap: { width: maxBubbleW - 20 }, align: 'center' }).setOrigin(0.5); |
| const bw = Math.min(txtR.width + 24, maxBubbleW); |
| const bh = txtR.height + 14; |
| const bgR = game.add.rectangle(bx, by - 10, bw, bh, 0xffffff, 0.95); |
| bgR.setStrokeStyle(2, 0x000000); |
| const bub = game.add.container(0, 0, [bgR, txtR]); |
| bub.setDepth(2700); |
| bub.__followAgentId = id; |
| guestBubbles[id] = bub; |
| setTimeout(() => { if (guestBubbles[id] === bub) { bub.destroy(); delete guestBubbles[id]; } }, 6000); |
| } |
| } |
| }); |
| |
| // 删除消失的 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]; |
| } |
| delete guestWanderState[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 statusThoughtsMapByLang = { |
| en: { |
| idle: ['Standing by in the break room', 'Taking a breather before next task', 'Recharging in the rest area'], |
| writing: ['Working on tasks in the office', 'Organizing docs and executing', 'Focused in the work zone'], |
| researching: ['In research mode, gathering info', 'Looking up references and verifying', 'Researching, will share findings soon'], |
| executing: ['Executing, running the pipeline', 'Pushing tasks forward at the desk', 'Turning plans into action'], |
| syncing: ['Syncing, updating status soon', 'Syncing progress to system', 'Data sync in progress, hold on'], |
| error: ['In the bug zone, investigating', 'Anomaly detected, fixing now', 'Alert mode, locating the issue'] |
| }, |
| zh: { |
| idle: ['在休息区待命中', '稍微放松一下,等下一步任务', '休息充电中'], |
| writing: ['在工作区处理任务', '正在整理文档', '工作区专注推进中'], |
| researching: ['调研模式,搜集信息中', '正在查资料和验证', '研究中,稍后同步结论'], |
| executing: ['执行中,正在跑流程', '在工作区推进任务', '正在把计划落地'], |
| syncing: ['同步中,马上更新状态', '正在同步进度', '数据同步中请稍候'], |
| error: ['在排查问题中', '检测到异常,正在修复', '报警中,先定位再处理'] |
| } |
| }; |
| const statusThoughtsMap = statusThoughtsMapByLang[chatLang] || statusThoughtsMapByLang.en; |
| const agentData = guestAgents.find(a => a.agentId === id) || {}; |
| const agentState = agentData.state || 'idle'; |
| const thoughts = statusThoughtsMap[agentState] || statusThoughtsMap.idle; |
| // Priority: API bubbleText > demo bubbleText > random thoughts |
| const apiBubble = agentData.bubbleText |
| ? (chatLang === 'zh' ? (agentData.bubbleTextZh || agentData.bubbleText) : agentData.bubbleText) |
| : null; |
| const text = apiBubble |
| ? apiBubble |
| : (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 ? 16 : 14; |
| const maxBubbleW = 300; |
| const txt = game.add.text(bx, by, text, { fontFamily: 'ArkPixel, monospace', fontSize: fontSize + 'px', fill: '#000', wordWrap: { width: maxBubbleW - 20 }, align: 'center' }).setOrigin(0.5); |
| const bw = Math.min(txt.width + 24, maxBubbleW); |
| const bh = txt.height + 14; |
| const bg = game.add.rectangle(bx, by, bw, bh, 0xffffff, 0.95); |
| bg.setStrokeStyle(2, 0x000000); |
| 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.en; |
| |
| // 确保两位 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 ? 16 : 14; |
| const bg = game.add.rectangle(bx, by, text.length * 11 + 30, 34, 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 访客可见 |
| // Filter out 'main' (HuggingClaw) — Star character already represents it |
| guestAgents = (Array.isArray(data) ? data : []).filter(a => a.agentId !== 'main'); |
| |
| // 新访客检测:触发 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.en; |
| const oldTexts = Array.isArray(langPack.writing) ? [...langPack.writing] : []; |
| langPack.writing = welcomeTexts[lang] || welcomeTexts.en; |
| 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); |
| initOfficePlaqueEditor(); |
| 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); |
| |
| // 同步 chatlog-panel 高度与 game-container(仅桌面端) |
| function syncChatlogHeight() { |
| const cl = document.getElementById('chatlog-panel'); |
| if (!cl) return; |
| // 移动端:清除 JS 设置的高度,让 CSS 接管 |
| if (window.innerWidth <= 900) { |
| cl.style.height = ''; |
| cl.style.maxHeight = ''; |
| return; |
| } |
| // 桌面端:同步高度到 game-container |
| const gc = document.getElementById('game-container'); |
| if (gc) { |
| const h = gc.offsetHeight; |
| if (h > 100) { |
| cl.style.height = h + 'px'; |
| cl.style.maxHeight = h + 'px'; |
| } |
| } |
| } |
| // 延迟等 canvas 渲染完毕后同步,之后每次 resize 也同步 |
| setTimeout(syncChatlogHeight, 500); |
| setTimeout(syncChatlogHeight, 1500); |
| window.addEventListener('resize', syncChatlogHeight); |
| |
| setTimeout(async () => { |
| try { |
| const authRes = await fetch('/assets/auth/status', { cache: 'no-store' }); |
| const authData = await authRes.json(); |
| if (authData && authData.ok && authData.authed) { |
| await applySavedPositionOverrides(); |
| } |
| } catch (e) {} |
| }, 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'); |
| // Electron standalone: force room background to fill the full 16:9 stage width. |
| if (officeBgSprite && officeBgSprite.setDisplaySize) { |
| officeBgSprite.setDisplaySize(1280, 720); |
| } |
| |
| // 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); |
| |
| // Name label above Star character — God (supervisor) |
| window._starNameLabel = game.add.text(areas.breakroom.x, areas.breakroom.y - 140, 'God', { |
| fontFamily: 'ArkPixel, monospace', |
| fontSize: '15px', |
| fill: '#ffffff', |
| stroke: '#000', |
| strokeThickness: 3 |
| }).setOrigin(0.5).setDepth(25); |
| |
| // Sofa stays static when idle (no longer the main idle animation) |
| sofa.anims.stop(); |
| sofa.setTexture('sofa_idle'); |
| |
| // 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'); |
| if (DESKTOP_MODE) { |
| statusText = document.getElementById('status-fab') || statusText; |
| } |
| 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 动态拉取并渲染到右侧访客列表 |
| if (coordsToggle) { |
| 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(); |
| if (DESKTOP_MODE && !isTouchDevice) { |
| camera.centerOn(MAP_W / 2, MAP_H / 2); |
| } |
| |
| 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 = `[${getStateLabelByState(currentState)}] ${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; } |
| |
| // Keep Star name label following the character |
| if (window._starNameLabel && star) { |
| window._starNameLabel.setPosition(star.x, star.y - 140); |
| } |
| |
| // 兜底:非 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; |
| } |
| |
| let lastChatlogLen = 0; |
| let chatLang = localStorage.getItem('chatLang') || 'en'; |
| // Sync uiLang to chatLang on init (single language toggle now) |
| uiLang = chatLang; |
| // Sync toggle button state on load |
| requestAnimationFrame(() => { |
| const enBtn = document.getElementById('chatlog-lang-en'); |
| const zhBtn = document.getElementById('chatlog-lang-zh'); |
| if (enBtn) enBtn.style.opacity = chatLang === 'en' ? '1' : '0.4'; |
| if (zhBtn) zhBtn.style.opacity = chatLang === 'zh' ? '1' : '0.4'; |
| }); |
| let chatlogCache = []; |
| function setChatLang(lang) { |
| chatLang = lang; |
| localStorage.setItem('chatLang', lang); |
| // Sync uiLang so Star bubbles and i18n also switch |
| uiLang = lang; |
| localStorage.setItem('uiLang', lang); |
| document.getElementById('chatlog-lang-en').style.opacity = lang === 'en' ? '1' : '0.4'; |
| document.getElementById('chatlog-lang-zh').style.opacity = lang === 'zh' ? '1' : '0.4'; |
| renderChatlog(); |
| // Clear existing guest bubbles so they re-render in new language |
| Object.keys(guestBubbles).forEach(k => { if (guestBubbles[k]) { guestBubbles[k].destroy(); delete guestBubbles[k]; } }); |
| // Also clear _lastBubbleText on guest sprites so bubbles re-create with new language |
| Object.values(guestSprites).forEach(g => { if (g) g._lastBubbleText = ''; }); |
| // Refresh Star's own bubble in new language |
| if (typeof showBubble === 'function') showBubble(); |
| } |
| function renderChatlog() { |
| const el = document.getElementById('chatlog-content'); |
| if (!el || chatlogCache.length === 0) return; |
| el.innerHTML = chatlogCache.slice().reverse().map(m => { |
| const cls = (m.speaker || '').toLowerCase(); |
| const text = chatLang === 'zh' ? (m.text_zh || m.text || '') : (m.text || ''); |
| const timeStr = m.time ? `<span class="chat-time">${m.time}</span> ` : ''; |
| return `<div class="chat-msg">${timeStr}<span class="chat-speaker ${cls}">${m.speaker}:</span> ${text}</div>`; |
| }).join(''); |
| el.scrollTop = 0; |
| } |
| function fetchChatlog() { |
| fetch('/api/chatlog?t=' + Date.now(), { cache: 'no-store' }) |
| .then(r => r.json()) |
| .then(data => { |
| const msgs = data.messages || []; |
| if (msgs.length === lastChatlogLen) return; |
| lastChatlogLen = msgs.length; |
| chatlogCache = msgs; |
| renderChatlog(); |
| }) |
| .catch(() => {}); |
| } |
| setInterval(fetchChatlog, 5000); |
| |
| function fetchStatus() { |
| return fetch('/status', { cache: 'no-store' }) |
| .then(response => response.json()) |
| .then(data => { |
| try { |
| if (data.officeName) { |
| window.officeNameFromServer = data.officeName; |
| refreshOfficePlaqueTitle(); |
| } |
| 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 = '[' + getStateLabelByState(nextState) + '] ' + (data.detail || getStateDetailByState(nextState)); |
| 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 = 'Status update error, recovering...'; |
| typewriterText = ''; |
| typewriterIndex = 0; |
| } |
| // Show Star bubble from API bubbleText |
| if (data.bubbleText && data.bubbleText !== window._lastStarBubbleText) { |
| window._lastStarBubbleText = data.bubbleText; |
| // Temporarily switch state to allow bubble display |
| const savedState = currentState; |
| currentState = 'writing'; |
| const lang = uiLang || 'en'; |
| const langPack = BUBBLE_TEXTS[lang] || BUBBLE_TEXTS.en; |
| const oldTexts = Array.isArray(langPack.writing) ? [...langPack.writing] : []; |
| const bubbleMsg = data.bubbleText.length > 30 ? data.bubbleText.slice(0, 30) + '…' : data.bubbleText; |
| langPack.writing = [bubbleMsg]; |
| showBubble(); |
| langPack.writing = oldTexts; |
| currentState = savedState; |
| } |
| }) |
| .catch(error => { |
| typewriterTarget = 'Connection failed, retrying...'; |
| 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[chatLang] || BUBBLE_TEXTS.en; |
| 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 ? 16 : 14; |
| const bg = game.add.rectangle(anchorX, bubbleY, text.length * 11 + 28, 34, 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 * 11 + 24, 28, 0xfffbeb, 0.95); |
| bg.setStrokeStyle(2, 0xd4a574); |
| const txt = game.add.text(anchorX, anchorY, text, { fontFamily: 'ArkPixel, monospace', fontSize: '13px', 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 数据为准 |
| |
| // 启动页面 |
| if (ASSET_WINDOW_MODE) { |
| applyLanguage(); |
| updateSpeedModeUI(); |
| toggleAssetDrawer(true); |
| } else { |
| initGame(); |
| } |
| </script> |
| </body> |
| </html> |
|
|