Enhancement script for Stellar Odyssey
// ==UserScript==
// @name StellarEnhancer
// @namespace https://stellarodyssey.app/
// @version 0.4.1
// @description Enhancement script for Stellar Odyssey
// @author SakakiChizuru
// @license MIT
// @icon https://game.stellarodyssey.app/favicon.ico
// @match https://stellarodyssey.app/*
// @match https://*.stellarodyssey.app/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @grant unsafeWindow
// @grant GM_registerMenuCommand
// @grant GM_notification
// @connect stellarodyssey.app
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
// ============================================================
// I18N
// ============================================================
const I18N = {
zh: {
// Panel
panelTitle: 'StellarOdyssey Enhancer',
langSwitch: 'Switch Language',
langSwitch: '切换语言',
lockTitleLocked: '已锁定到 Toolbar',
lockTitleUnlock: '点击锁定到 Toolbar',
minimizeExpand: '展开',
minimizeMinimize: '最小化',
// Font
fontLabel: '字体:',
fontBtn: '选择字体',
fontModalTitle: '选择字体',
fontLoading: '正在加载字体列表...',
fontSample: '字体样式',
// Coordinate
coordLabel: '监听坐标',
// autoNavLabel: '自动导航',
// coordTargetLabel: '目标位置',
coordSimLink: '本功能与 <a href="https://sakakichizuru.github.io/StellarOdysseySim/" target="_blank" rel="noopener noreferrer">模拟器</a> 联动',
// Rune Monitor
runeMonitorLabel: '监听符文发现',
debugChatBtn: 'DEBUG 聊天',
runeEmpty: '暂无符文发现',
runeCollectedTitle: '已访问获取',
runeBy: 'by ',
runeRemaining: ' · 剩余 ',
runeExpired: '已过期',
runeNotifyTitle: '符文发现!',
runeNotifyBody: (type, num, x, y, player) => `${type} #${num} 在 [${x}, ${y}],由 ${player} 发现`,
// Rune Collection Tracker
runeTrackLabel: '记录已收集符文',
runeTrackBtnRead: '读取已收集',
runeTrackWaiting: '等待数据...',
runeTrackRecorded: '已记录 {n}/{total}',
runeTrackDone: '✓ 记录完成',
// GM Menu
gmResetPos: '重置 PANEL 位置',
gmResetPosAlert: 'PANEL 位置已重置到 0,0',
},
en: {
panelTitle: 'StellarOdyssey Enhancer',
lockTitleLocked: 'Locked to Toolbar',
lockTitleUnlock: 'Click to lock to Toolbar',
minimizeExpand: 'Expand',
minimizeMinimize: 'Minimize',
// Font
fontLabel: 'Font:',
fontBtn: 'Select Font',
fontModalTitle: 'Select Font',
fontLoading: 'Loading font list...',
fontSample: 'Sample',
// Coordinate
coordLabel: 'Listen Coords',
// autoNavLabel: 'Auto Navigate',
// coordTargetLabel: 'Target Position',
coordSimLink: 'Works with <a href="https://sakakichizuru.github.io/StellarOdysseySim/" target="_blank" rel="noopener noreferrer">Simulator</a>',
// Rune Monitor
runeMonitorLabel: 'Rune Discovery',
debugChatBtn: 'DEBUG Chat',
runeEmpty: 'No runes found',
runeCollectedTitle: 'Collected',
runeBy: 'by ',
runeRemaining: ' · Left ',
runeExpired: 'Expired',
runeNotifyTitle: 'Rune Found!',
runeNotifyBody: (type, num, x, y, player) => `${type} #${num} at [${x}, ${y}] by ${player}`,
// Rune Collection Tracker
runeTrackLabel: 'Track Collected Runes',
runeTrackBtnRead: 'Read Collection',
runeTrackWaiting: 'Waiting...',
runeTrackRecorded: '{n}/{total}',
runeTrackDone: '✓ Done',
// GM Menu
gmResetPos: 'Reset Panel Position',
gmResetPosAlert: 'Panel position reset to 0,0',
},
};
// 检测语言:GM存储优先,fallback 到浏览器语言
let _lang = GM_getValue('SE_language', null);
if (!_lang) {
_lang = (typeof navigator !== 'undefined' && /^zh/i.test(navigator.language)) ? 'zh' : 'en';
}
/** 切换语言 */
function setLanguage(lang) {
_lang = lang;
GM_setValue('SE_language', lang);
console.log(`[StellarEnhancer] language switched to: ${_lang}`);
}
/** 翻译函数 */
function t(key) { return (_lang === 'zh' ? I18N.zh : I18N.en)[key] || key; }
console.log(`[StellarEnhancer] v0.3.0 loaded (lang: ${_lang})`);
// ============================================================
// Utils
// ============================================================
const SE = {
version: '0.1.0',
get(key, defaultValue) {
return GM_getValue(`SE_${key}`, defaultValue);
},
set(key, value) {
GM_setValue(`SE_${key}`, value);
},
addStyle(css) {
GM_addStyle(css);
},
};
// ============================================================
// Styles
// ============================================================
SE.addStyle(`
/* ===== Main Panel ===== */
.se-panel {
position: fixed;
z-index: 99999;
background: rgba(10, 15, 30, 0.95);
border: 1px solid #2a4a8a;
border-radius: 0;
color: #c8d8f8;
font-family: 'Segoe UI', sans-serif;
font-size: 12px;
box-shadow: 0 2px 12px rgba(0, 60, 150, 0.5);
min-width: 280px;
user-select: none;
}
.se-panel-title {
background: #1a3060;
border-bottom: 1px solid #2a4a8a;
padding: 4px 8px;
font-size: 12px;
font-weight: 600;
color: #7ab8ff;
cursor: move;
display: flex;
align-items: center;
justify-content: space-between;
height: 24px;
box-sizing: border-box;
overflow: visible;
}
.se-panel-title:hover {
background: #1e3a70;
}
.se-panel-title.se-locked {
cursor: default;
}
.se-panel-title.se-locked:hover {
background: #1a3060;
}
.se-panel-title-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.se-panel-minimize {
background: none;
border: none;
color: #ffffff;
font-size: 14px;
cursor: pointer;
padding: 0 4px;
line-height: 1;
margin-left: 4px;
transition: color 0.15s;
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
}
.se-panel-minimize:hover {
color: #a0c8ff;
}
.se-panel-minimize svg {
width: 14px;
height: 14px;
fill: currentColor;
}
.se-panel-lock {
background: none;
border: none;
color: #a0c8ff;
cursor: pointer;
padding: 0 4px;
line-height: 1;
margin-left: 4px;
transition: color 0.15s;
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
}
.se-panel-lock:hover {
color: #ffffff;
}
.se-panel-lock svg {
width: 14px;
height: 14px;
fill: currentColor;
}
.se-lang-btn {
background: none;
border: none;
color: #8cb4e0;
cursor: pointer;
padding: 0 4px;
line-height: 1;
margin-left: auto;
transition: color 0.15s;
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
}
.se-lang-btn:hover {
color: #a0c8ff;
}
.se-lang-btn svg {
width: 14px;
height: 14px;
fill: currentColor;
}
.se-debug-btn {
background: none;
border: none;
color: #8cb4e0;
cursor: pointer;
padding: 0 4px;
line-height: 1;
transition: color 0.15s;
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
}
.se-debug-btn:hover {
color: #a0c8ff;
}
.se-debug-btn svg {
width: 14px;
height: 14px;
fill: currentColor;
}
/* 语言下拉浮层 — 挂在 body 下,独立于 panel */
#se-lang-dropdown {
display: none;
position: fixed;
z-index: 999999;
background: rgba(10, 20, 40, 0.98);
border: 1px solid #3a5a9a;
border-radius: 4px;
padding: 2px 0;
min-width: 90px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
}
.se-lang-option {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
text-align: left;
background: none;
border: none;
color: #c8d8f8;
padding: 6px 12px;
font-size: 12px;
cursor: pointer;
line-height: 1.4;
}
.se-lang-option:hover {
background: #1e3a70;
color: #fff;
}
.se-panel-locked {
color: #5a9aff;
}
.se-panel.minimized .se-panel-body {
display: none;
}
.se-panel.minimized {
min-width: auto;
width: auto;
}
.se-panel-body {
padding: 8px 10px;
}
.se-font-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.se-font-label {
color: #a0c8ff;
white-space: nowrap;
}
.se-font-name {
color: #7ab8ff;
font-weight: 500;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.se-btn {
display: inline-block;
padding: 3px 10px;
background: #1a3060;
border: 1px solid #2a5aaa;
border-radius: 0;
color: #a0c8ff;
cursor: pointer;
font-size: 11px;
transition: all 0.15s;
white-space: nowrap;
}
.se-btn:hover {
background: #2a4a9a;
color: #ffffff;
border-color: #3a6aca;
}
/* ===== Font Picker Modal ===== */
.se-font-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 100000;
display: flex;
align-items: center;
justify-content: center;
}
.se-font-modal {
background: rgba(10, 15, 30, 0.98);
border: 1px solid #2a4a8a;
border-radius: 0;
width: 90%;
max-width: 900px;
max-height: 85vh;
display: flex;
flex-direction: column;
box-shadow: 0 4px 32px rgba(0, 60, 150, 0.6);
}
.se-font-modal-header {
background: #1a3060;
border-bottom: 1px solid #2a4a8a;
padding: 10px 16px;
display: flex;
align-items: center;
justify-content: space-between;
}
.se-font-modal-title {
font-size: 16px;
font-weight: 600;
color: #7ab8ff;
letter-spacing: 0.5px;
}
.se-font-modal-close {
background: none;
border: none;
color: #a0c8ff;
font-size: 24px;
cursor: pointer;
padding: 0 4px;
line-height: 1;
transition: color 0.2s;
}
.se-font-modal-close:hover {
color: #ff6b6b;
}
.se-font-modal-body {
padding: 20px;
overflow-y: auto;
flex: 1;
}
.se-font-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
}
.se-font-block {
background: rgba(20, 35, 70, 0.6);
border: 1px solid #2a4a8a;
border-radius: 0;
padding: 12px 8px;
text-align: center;
cursor: pointer;
transition: all 0.15s;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 90px;
}
.se-font-block:hover {
background: rgba(30, 55, 110, 0.8);
border-color: #3a6aca;
box-shadow: 0 2px 8px rgba(0, 80, 200, 0.3);
}
.se-font-block.selected {
background: rgba(40, 80, 160, 0.6);
border-color: #5a9aff;
box-shadow: 0 0 12px rgba(90, 154, 255, 0.4);
}
.se-font-block-preview {
font-size: 24px;
color: #c8d8f8;
margin-bottom: 6px;
line-height: 1.2;
}
.se-font-block-sample {
font-size: 13px;
color: #a0c8ff;
margin-bottom: 6px;
line-height: 1.3;
padding: 2px 4px;
background: rgba(0, 0, 0, 0.2);
border-radius: 2px;
}
.se-font-block-name {
font-size: 10px;
color: #7ab8ff;
word-break: break-word;
line-height: 1.2;
max-width: 100%;
}
.se-font-loading {
text-align: center;
color: #7ab8ff;
padding: 40px;
font-size: 14px;
}
/* ===== Coordinate Listener ===== */
.se-coord-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid #2a4a8a;
}
.se-checkbox {
appearance: none;
width: 14px;
height: 14px;
border: 1px solid #2a5aaa;
background: #1a3060;
cursor: pointer;
position: relative;
}
.se-checkbox:checked {
background: #2a4a9a;
border-color: #3a6aca;
}
.se-checkbox:checked::after {
content: '';
position: absolute;
left: 4px;
top: 1px;
width: 4px;
height: 8px;
border: solid #ffffff;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.se-checkbox-label {
color: #a0c8ff;
cursor: pointer;
user-select: none;
}
.se-checkbox-label.disabled {
color: #5a6a8a;
cursor: not-allowed;
}
.se-coord-sim-hint {
margin-top: 4px;
padding: 0 2px;
font-size: 10px;
color: #5a7aaa;
line-height: 1.4;
}
.se-coord-sim-hint a {
color: #6aaaff;
text-decoration: none;
}
.se-coord-sim-hint a:hover {
color: #8ac8ff;
text-decoration: underline;
}
.se-coord-display {
margin-top: 8px;
padding: 6px 8px;
background: rgba(20, 35, 70, 0.6);
border: 1px solid #2a4a8a;
font-size: 11px;
color: #7ab8ff;
display: none;
}
.se-coord-display.active {
display: block;
}
.se-coord-display .coord-value {
color: #5a9aff;
font-weight: 600;
}
/* ===== Rune Monitor ===== */
.se-panel-title.se-rune-alert {
background: #1a1a2e;
border-bottom-color: #ffffff;
animation: se-rainbow-bg 2s linear infinite;
background-size: 200% 100%;
}
@keyframes se-rainbow-bg {
0% { background-position: -200% center; }
100% { background-position: 200% center; }
}
.se-panel-title-text.se-rune-alert-text {
background: linear-gradient(90deg,
#ff0000, #ff8000, #ffff00, #00ff00,
#0080ff, #0000ff, #8000ff, #ff00ff, #ff0000);
background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
-webkit-text-fill-color: transparent;
animation: se-rainbow-text 1.5s linear infinite;
}
@keyframes se-rainbow-text {
0% { background-position: -200% center; }
100% { background-position: 200% center; }
}
.se-rune-list {
margin-top: 8px;
padding: 6px 8px;
background: rgba(40, 20, 20, 0.6);
border: 1px solid #8b3030;
font-size: 11px;
display: none;
}
.se-rune-list.active {
display: block;
}
.se-rune-item {
padding: 4px 0;
border-bottom: 1px solid rgba(139, 48, 48, 0.4);
line-height: 1.5;
}
.se-rune-item:last-child {
border-bottom: none;
}
.se-rune-type {
color: #ff9944;
font-weight: 600;
}
.se-rune-coord {
color: #66aaff;
}
.se-rune-player {
color: #a0c8ff;
}
.se-rune-time {
color: #889999;
font-size: 10px;
}
.se-rune-countdown {
color: #ff6666;
font-weight: 500;
}
.se-rune-item {
display: flex;
align-items: flex-start;
gap: 6px;
}
.se-rune-item-body {
flex: 1;
min-width: 0;
}
.se-rune-collected .se-rune-type,
.se-rune-collected .se-rune-coord,
.se-rune-collected .se-rune-player {
opacity: 0.4;
text-decoration: line-through;
}
.se-rune-collected .se-rune-countdown {
opacity: 0.4;
}
.se-rune-cb-wrap {
flex-shrink: 0;
padding-top: 2px;
}
.se-rune-cb-wrap input[type="checkbox"] {
appearance: none;
width: 13px;
height: 13px;
border: 1px solid #5a8a5a;
background: #1a3020;
cursor: pointer;
position: relative;
flex-shrink: 0;
}
.se-rune-cb-wrap input[type="checkbox"]:checked {
background: #2a5a2a;
border-color: #4a8a4a;
}
.se-rune-cb-wrap input[type="checkbox"]:checked::after {
content: '';
position: absolute;
left: 3px;
top: 0;
width: 4px;
height: 7px;
border: solid #8cfc8c;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
/* ===== Rune Collection Tracker ===== */
.se-rune-track-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
margin-top: 6px;
}
.se-rune-track-status {
font-size: 10px;
color: #6a8a6a;
margin-left: auto;
white-space: nowrap;
}
/* ===== Read Collection Button ===== */
.se-rune-read-btn {
display: inline-block;
padding: 2px 10px;
background: #1a3060;
border: 1px solid #2a5aaa;
border-radius: 0;
color: #a0c8ff;
cursor: pointer;
font-size: 11px;
transition: all 0.15s;
white-space: nowrap;
margin-left: auto;
}
.se-rune-read-btn:hover {
background: #2a4a9a;
color: #ffffff;
border-color: #3a6aca;
}
.se-rune-read-btn.se-active {
border-color: #aa5a00;
color: #ffcc66;
background: #3a2800;
}
`);
// ============================================================
// Main Module
// ============================================================
const StellarEnhancer = {
currentFont: SE.get('selectedFont', 'Segoe UI'),
modal: null,
fontList: [],
fontLockInterval: null,
positionLockInterval: null,
init() {
// 销毁旧面板,避免重复创建
if (this.panel) {
this.panel.remove();
this.panel = null;
}
// 销毁旧的下拉菜单
const oldDropdown = document.getElementById('se-lang-dropdown');
if (oldDropdown) oldDropdown.remove();
this.createMainPanel();
// 如果有保存的字体,启动锁定
if (this.currentFont && this.currentFont !== 'Segoe UI') {
this.applyFont();
this.startFontLock();
}
// 如果保存了监听坐标状态,启动监听
if (this.isListeningCoord) {
this.startCoordListener();
}
// 如果保存了监听符文发现状态,自动启动
if (this.isRuneMonitor) {
this.startRuneMonitor();
}
// 更新收集追踪状态显示
this.updateTrackStatus();
// 如果保存了收集追踪状态,自动启动 galaxy 自动检测
if (this.isRuneTracking) {
this.startGalaxyAutoTrack();
}
// 检查是否有待处理的自动导航(页面跳转后恢复)
this.checkPendingAutoNav();
// 监听 URL 变化,离开 galaxy 页面时重置 canvas 状态
this.setupUrlChangeHandler();
},
setupUrlChangeHandler() {
// 保存当前 URL
this._lastUrl = window.location.href;
// 使用 popstate 事件监听浏览器前进/后退
this._popstateHandler = () => {
this._checkUrlChange();
};
window.addEventListener('popstate', this._popstateHandler);
// 使用 MutationObserver 监听 URL 变化(用于 SPA 路由切换)
this._urlObserver = new MutationObserver(() => {
if (window.location.href !== this._lastUrl) {
this._checkUrlChange();
}
});
this._urlObserver.observe(document, { subtree: true, childList: true });
},
_checkUrlChange() {
const currentUrl = window.location.href;
const isInGalaxy = currentUrl.includes('/#/galaxy');
// 只要不在 galaxy 页面,canvas 状态就重置为未初始化
if (!isInGalaxy && this.canvasReady) {
this.canvasReady = false;
}
this._lastUrl = currentUrl;
},
checkPendingAutoNav() {
// no-op in release
return;
},
isMinimized: false,
isLocked: false,
lastToolbarRect: null,
isListeningCoord: false,
isAutoNav: false,
lastCoord: null,
isNavigating: false, // 导航执行状态锁
canvasReady: false, // canvas是否已初始化完成
// SVG 图标
icons: {
lockOpen: `<svg viewBox="0 0 24 24"><path d="M12 17c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm6-9h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6h1.9c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm0 12H6V10h12v10z"/></svg>`,
lockClosed: `<svg viewBox="0 0 24 24"><path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z"/></svg>`,
minimize: `<svg viewBox="0 0 24 24"><path d="M19 13H5v-2h14v2z"/></svg>`,
expand: `<svg viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>`,
language: `<svg viewBox="0 0 24 24"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm6.93 6h-2.95a15.65 15.65 0 00-1.38-3.56c1.84.63 3.37 1.91 4.33 3.56zM12 4.04c.83 1.2 1.48 2.53 1.91 3.96h-3.82c.43-1.43 1.08-2.76 1.91-3.96zM4.26 14C4.1 13.36 4 12.69 4 12s.1-1.36.26-2h3.38c-.08.66-.14 1.32-.14 2s.06 1.34.14 2H4.26zm.82 2h2.95c.32 1.25.78 2.45 1.38 3.56-1.84-.63-3.37-1.9-4.33-3.56zm2.95-8H5.08c.96-1.66 2.49-2.93 4.33-3.56C8.81 5.55 8.35 6.75 8.03 8zM12 19.96c-.83-1.2-1.48-2.53-1.91-3.96h3.82c-.43 1.43-1.08 2.76-1.91 3.96zM14.34 14H9.66c-.09-.66-.16-1.32-.16-2s.07-1.35.16-2h4.68c.09.65.16 1.32.16 2s-.07 1.34-.16 2zm.25 5.56c.6-1.11 1.06-2.31 1.38-3.56h2.95c-.96 1.65-2.49 2.93-4.33 3.56zM16.36 14c.08-.66.14-1.32.14-2s-.06-1.34-.14-2h3.38c.16.64.26 1.31.26 2s-.1 1.36-.26 2h-3.38z"/></svg>`,
check: `<svg viewBox="0 0 24 24" style="width:12px;height:12px;fill:#8cfc8c;"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/></svg>`,
bug: `<svg viewBox="0 0 24 24"><path d="M20 8h-2.81c-.45-.78-1.07-1.45-1.82-1.96L17 4.41 15.59 3l-1.79 1.79C13.03 4.57 12.54 4.38 12 4.28V2h-1.5v2.28c-.54.1-1.03.29-1.5.56L7.21 3 5.79 4.41l1.62 1.63C6.66 6.55 6.04 7.22 5.59 8H2.81v1.5h2.25c-.04.33-.06.66-.06 1s.02.67.06 1H2.81V13h2.78c.45.78 1.07 1.45 1.82 1.96L5.79 16.59 7.21 18l1.79-1.79c.47.27.96.46 1.5.56V19h1.5v-2.23c.54-.09 1.03-.28 1.5-.56L15.59 18 17 16.59l-1.62-1.63c.75-.51 1.37-1.18 1.82-1.96H20v-1.5h-2.25c.05-.33.06-.66.06-1s-.01-.67-.06-1H20V8zm-7 5c-1.65 0-3-1.35-3-3s1.35-3 3-3 3 1.35 3 3-1.35 3-3 3z"/></svg>`
},
createMainPanel() {
const panel = document.createElement('div');
panel.className = 'se-panel';
panel.id = 'se-main-panel';
// 初始隐藏,等待定位完成后再显示
panel.style.visibility = 'hidden';
// 检查是否有保存的状态
this.isMinimized = SE.get('panelMinimized', false);
this.isLocked = SE.get('panelLocked', false);
this.isListeningCoord = SE.get('listenCoordinate', false);
this.isAutoNav = SE.get('autoNavigate', false);
this.isRuneMonitor = SE.get('runeMonitor', false);
this.isRuneTracking = SE.get('runeTracking', false);
if (this.isMinimized) {
panel.classList.add('minimized');
}
const lockIcon = this.isLocked ? this.icons.lockClosed : this.icons.lockOpen;
const lockTitle = this.isLocked ? t('lockTitleLocked') : t('lockTitleUnlock');
const lockClass = this.isLocked ? 'se-panel-lock se-panel-locked' : 'se-panel-lock';
const titleLockedClass = this.isLocked ? 'se-locked' : '';
const minimizeIcon = this.isMinimized ? this.icons.expand : this.icons.minimize;
const minimizeTitle = this.isMinimized ? t('minimizeExpand') : t('minimizeMinimize');
const coordChecked = this.isListeningCoord ? 'checked' : '';
const coordDisplayClass = this.isListeningCoord ? 'active' : '';
const autoNavChecked = this.isAutoNav ? 'checked' : '';
const autoNavDisabled = this.isListeningCoord ? '' : 'disabled';
const runeChecked = this.isRuneMonitor ? 'checked' : '';
const runeTrackChecked = this.isRuneTracking ? 'checked' : '';
panel.innerHTML = `
<div class="se-panel-title ${titleLockedClass}" id="se-panel-title">
<span class="se-panel-title-text">${t('panelTitle')}</span>
<button class="se-debug-btn" id="se-debug-btn" title="Debug: Dump GM Storage">
${this.icons.bug}
</button>
<button class="se-lang-btn" id="se-lang-btn" title="${t('langSwitch')}">
${this.icons.language}
</button>
<button class="${lockClass}" id="se-lock-btn" title="${lockTitle}">${lockIcon}</button>
<button class="se-panel-minimize" id="se-minimize-btn" title="${minimizeTitle}">${minimizeIcon}</button>
</div>
<div class="se-panel-body">
<div class="se-font-row">
<span class="se-font-label">${t('fontLabel')}</span>
<span class="se-font-name" id="se-current-font" style="font-family: '${this.currentFont}';">${this.currentFont}</span>
<button class="se-btn" id="se-font-btn">${t('fontBtn')}</button>
</div>
<div class="se-coord-row">
<input type="checkbox" class="se-checkbox" id="se-coord-checkbox" ${coordChecked}>
<label class="se-checkbox-label" for="se-coord-checkbox">${t('coordLabel')}</label>
</div>
<div class="se-coord-sim-hint">${t('coordSimLink')}</div>
<div class="se-coord-display ${coordDisplayClass}" id="se-coord-display">
<span class="coord-value" id="se-coord-value"></span>
</div>
<div class="se-coord-row">
<input type="checkbox" class="se-checkbox" id="se-rune-monitor-checkbox" ${runeChecked}>
<label class="se-checkbox-label" for="se-rune-monitor-checkbox">${t('runeMonitorLabel')}</label>
<button class="se-btn" id="se-debug-chat-btn" style="display:none;">${t('debugChatBtn')}</button>
</div>
<div class="se-rune-track-row">
<input type="checkbox" class="se-checkbox" id="se-rune-track-checkbox" ${runeTrackChecked}>
<label class="se-checkbox-label" for="se-rune-track-checkbox">${t('runeTrackLabel')}</label>
<button class="se-rune-read-btn" id="se-rune-read-btn" style="${this.isRuneTracking ? '' : 'display:none;'}">${t('runeTrackBtnRead')}</button>
</div>
<div class="se-rune-list ${this.isRuneMonitor ? 'active' : ''}" id="se-rune-list"></div>
</div>
`;
document.body.appendChild(panel);
this.panel = panel;
// 绑定锁定按钮
panel.querySelector('#se-lock-btn').addEventListener('click', (e) => {
e.stopPropagation();
this.toggleLock();
});
// 绑定最小化按钮
panel.querySelector('#se-minimize-btn').addEventListener('click', (e) => {
e.stopPropagation();
this.toggleMinimize();
});
// 绑定拖动
this.makeDraggable(panel, panel.querySelector('#se-panel-title'));
// 绑定字体按钮
panel.querySelector('#se-font-btn').addEventListener('click', () => {
this.openFontModal();
});
// 绑定坐标监听 checkbox
panel.querySelector('#se-coord-checkbox').addEventListener('change', (e) => {
this.toggleCoordListener(e.target.checked);
});
// 绑定 DEBUG 聊天按钮
panel.querySelector('#se-debug-chat-btn').addEventListener('click', () => {
this.debugChatLog();
});
// 绑定监听符文发现 checkbox
panel.querySelector('#se-rune-monitor-checkbox').addEventListener('change', (e) => {
this.toggleRuneMonitor(e.target.checked);
});
// 绑定记录已收集符文 checkbox
panel.querySelector('#se-rune-track-checkbox').addEventListener('change', (e) => {
this.toggleRuneTracking(e.target.checked);
});
// 绑定读取已收集按钮
panel.querySelector('#se-rune-read-btn').addEventListener('click', () => {
if (this._isReadingCollection) {
// 再次点击可手动退出
this.stopReadCollection();
} else {
this.startReadCollection();
}
});
// 绑定语言切换按钮 — 独立浮层下拉菜单
const langBtn = panel.querySelector('#se-lang-btn');
// 创建独立浮层(挂在 body 下,不受 panel 布局影响)
const langDropdown = document.createElement('div');
langDropdown.id = 'se-lang-dropdown';
// 根据当前语言显示勾选标记
const checkZh = _lang === 'zh' ? this.icons.check : '';
const checkEn = _lang === 'en' ? this.icons.check : '';
langDropdown.innerHTML =
'<button class="se-lang-option" data-lang="zh">' + checkZh + ' 中文</button>' +
'<button class="se-lang-option" data-lang="en">' + checkEn + ' English</button>';
document.body.appendChild(langDropdown);
// 选项点击事件
langDropdown.querySelectorAll('.se-lang-option').forEach(opt => {
opt.addEventListener('click', (e) => {
e.stopPropagation();
setLanguage(opt.dataset.lang);
const wasRuneActive = this.isRuneMonitor;
// 语言切换时保留符文数据,仅停止 observer/timer
if (wasRuneActive) this.pauseRuneMonitor();
langDropdown.remove();
this.init(); // 重新渲染整个面板
// 语言切换后重启符文监听(触发重新扫描)
if (wasRuneActive) this.startRuneMonitor();
});
});
// hover 显示 / 隐藏 — 锚点:按钮左下角
langBtn.addEventListener('mouseenter', () => {
const rect = langBtn.getBoundingClientRect();
langDropdown.style.left = rect.left + 'px';
langDropdown.style.top = rect.bottom + 4 + 'px';
langDropdown.style.display = 'block';
});
langBtn.addEventListener('mouseleave', () => {
setTimeout(() => {
if (langDropdown.parentElement && !langDropdown.matches(':hover')) {
langDropdown.style.display = 'none';
}
}, 100);
});
langDropdown.addEventListener('mouseleave', () => {
langDropdown.style.display = 'none';
});
// 绑定 Debug 按钮:点击打印所有 GM 存储内容到控制台
panel.querySelector('#se-debug-btn').addEventListener('click', () => {
console.log('%c[StellarEnhancer] ===== GM Storage Dump =====', 'color:#00ccff;font-weight:bold;font-size:13px');
const prefix = 'SE_';
// 遍历所有可能的存储 key
const knownKeys = [
'panelX', 'panelY', 'panelMinimized', 'panelLocked',
'currentFont', 'listenCoordinate', 'autoNavigate',
'autoNavTarget', 'pendingAutoNav',
'runeMonitor', 'runeTracking', 'runeCollection',
'language'
];
for (const key of knownKeys) {
const fullKey = prefix + key;
try {
const val = SE.get(key, '__NOT_SET__');
if (val === '__NOT_SET__') {
console.log(` ${fullKey}: (not set)`);
} else if (typeof val === 'object') {
console.log(` ${fullKey}:`, JSON.parse(JSON.stringify(val)));
} else {
console.log(` ${fullKey}:`, val);
}
} catch (e) {
console.warn(` ${fullKey}: (read error)`, e);
}
}
console.log('%c[StellarEnhancer] ===== End Dump =====', 'color:#00ccff;font-weight:bold;font-size:13px');
});
// 等待 toolbar 出现后定位
this.waitForToolbarAndPosition();
},
toggleLock() {
this.isLocked = !this.isLocked;
SE.set('panelLocked', this.isLocked);
const lockBtn = this.panel.querySelector('#se-lock-btn');
const titleEl = this.panel.querySelector('#se-panel-title');
if (this.isLocked) {
lockBtn.innerHTML = this.icons.lockClosed;
lockBtn.title = t('lockTitleLocked');
lockBtn.classList.add('se-panel-locked');
// 标题栏添加锁定样式
titleEl.classList.add('se-locked');
// 清除保存的位置,下次刷新会重新停靠到 toolbar
SE.set('panelPos', null);
// 延迟重新定位到 toolbar,确保获取最新位置
setTimeout(() => {
const toolbar = document.querySelector('div[role="toolbar"]');
if (toolbar) {
this.positionToToolbar(toolbar);
this.panel.style.visibility = 'visible';
// 启动位置锁定轮询
this.startPositionLock();
}
}, 50);
} else {
lockBtn.innerHTML = this.icons.lockOpen;
lockBtn.title = t('lockTitleUnlock');
lockBtn.classList.remove('se-panel-locked');
// 标题栏移除锁定样式
titleEl.classList.remove('se-locked');
// 停止位置锁定轮询
this.stopPositionLock();
// 保存当前位置
const currentPos = { left: this.panel.offsetLeft, top: this.panel.offsetTop };
SE.set('panelPos', currentPos);
}
},
startPositionLock() {
// 清除已有的定时器
this.stopPositionLock();
// 记录当前 toolbar 位置
const toolbar = document.querySelector('div[role="toolbar"]');
if (toolbar) {
const firstDiv = toolbar.querySelector('div');
this.lastToolbarRect = firstDiv ? firstDiv.getBoundingClientRect() : toolbar.getBoundingClientRect();
}
// 每 200ms 检测一次位置
this.positionLockInterval = setInterval(() => {
this.checkAndAdjustPosition();
}, 200);
},
stopPositionLock() {
if (this.positionLockInterval) {
clearInterval(this.positionLockInterval);
this.positionLockInterval = null;
}
this.lastToolbarRect = null;
},
checkAndAdjustPosition() {
if (!this.isLocked || !this.panel) return;
const toolbar = document.querySelector('div[role="toolbar"]');
if (!toolbar) return;
const firstDiv = toolbar.querySelector('div');
const currentRect = firstDiv ? firstDiv.getBoundingClientRect() : toolbar.getBoundingClientRect();
// 计算期望位置
const panelHeight = 24;
const panelWidth = this.panel.offsetWidth || 280;
const targetCenterY = currentRect.top + currentRect.height / 2;
let expectedTop = targetCenterY - panelHeight / 2;
let expectedLeft = currentRect.right + 10;
// 限制在视口范围内
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
if (expectedLeft + panelWidth > viewportWidth) {
expectedLeft = currentRect.left - panelWidth - 10;
}
expectedLeft = Math.max(0, expectedLeft);
expectedTop = Math.max(0, expectedTop);
expectedTop = Math.min(expectedTop, viewportHeight - panelHeight);
// 获取当前位置
const currentLeft = parseFloat(this.panel.style.left) || 0;
const currentTop = parseFloat(this.panel.style.top) || 0;
// 如果位置偏差超过 1px,则调整
if (Math.abs(currentLeft - expectedLeft) > 1 || Math.abs(currentTop - expectedTop) > 1) {
this.panel.style.left = expectedLeft + 'px';
this.panel.style.top = expectedTop + 'px';
}
// 更新记录的 toolbar 位置
this.lastToolbarRect = currentRect;
},
waitForToolbarAndPosition() {
// 如果锁定状态,优先停靠到 toolbar
if (this.isLocked) {
// 轮询等待 toolbar 出现
this.waitForToolbarAndShow();
return;
}
// 尝试恢复保存的位置
const savedPos = SE.get('panelPos', null);
if (savedPos) {
// 检测保存的位置是否超出浏览器可见区域
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const panelWidth = this.panel.offsetWidth || 280;
const panelHeight = this.panel.offsetHeight || 80;
const isOutOfBounds = savedPos.left < 0 || savedPos.top < 0 ||
savedPos.left + panelWidth > viewportWidth ||
savedPos.top + panelHeight > viewportHeight;
if (isOutOfBounds) {
// 超出边界,强制回退到 0,0
this.panel.style.left = '0px';
this.panel.style.top = '0px';
SE.set('panelPos', { left: 0, top: 0 });
} else {
this.panel.style.left = savedPos.left + 'px';
this.panel.style.top = savedPos.top + 'px';
}
this.panel.style.visibility = 'visible';
return;
}
// 轮询等待 toolbar 出现
let attempts = 0;
const maxAttempts = 100; // 最多等待 10 秒
const checkToolbar = () => {
const toolbar = document.querySelector('div[role="toolbar"]');
if (toolbar) {
this.positionToToolbar(toolbar);
this.panel.style.visibility = 'visible';
return;
}
attempts++;
if (attempts < maxAttempts) {
setTimeout(checkToolbar, 100);
} else {
// 超时 fallback
this.panel.style.left = '20px';
this.panel.style.top = '20px';
this.panel.style.visibility = 'visible';
console.warn('[StellarEnhancer] Toolbar not found, using default position');
}
};
checkToolbar();
},
waitForToolbarAndShow() {
let attempts = 0;
const maxAttempts = 100;
const checkToolbar = () => {
const toolbar = document.querySelector('div[role="toolbar"]');
if (toolbar) {
this.positionToToolbar(toolbar);
this.panel.style.visibility = 'visible';
// 启动位置锁定轮询
this.startPositionLock();
return;
}
attempts++;
if (attempts < maxAttempts) {
setTimeout(checkToolbar, 100);
} else {
this.panel.style.left = '20px';
this.panel.style.top = '20px';
this.panel.style.visibility = 'visible';
}
};
checkToolbar();
},
positionToToolbar(toolbar) {
// 获取 toolbar 下的第一个 div
const firstDiv = toolbar.querySelector('div');
const targetRect = firstDiv ? firstDiv.getBoundingClientRect() : toolbar.getBoundingClientRect();
const panelHeight = 24; // title height
const panelWidth = this.panel.offsetWidth || 280; // 默认宽度
// 计算垂直居中位置(相对于 target)
const targetCenterY = targetRect.top + targetRect.height / 2;
let panelTop = targetCenterY - panelHeight / 2;
// 停靠在 target 右侧,留 10px 间距
let panelLeft = targetRect.right + 10;
// 限制在视口范围内
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// 如果右侧超出屏幕,改为显示在 target 左侧
if (panelLeft + panelWidth > viewportWidth) {
panelLeft = targetRect.left - panelWidth - 10;
}
// 确保不超出左边界
panelLeft = Math.max(0, panelLeft);
// 确保不超出上边界
panelTop = Math.max(0, panelTop);
// 确保不超出下边界
panelTop = Math.min(panelTop, viewportHeight - panelHeight);
this.panel.style.left = panelLeft + 'px';
this.panel.style.top = panelTop + 'px';
},
toggleMinimize() {
this.isMinimized = !this.isMinimized;
SE.set('panelMinimized', this.isMinimized);
const minimizeBtn = this.panel.querySelector('#se-minimize-btn');
if (this.isMinimized) {
this.panel.classList.add('minimized');
minimizeBtn.innerHTML = this.icons.expand;
minimizeBtn.title = t('minimizeExpand');
} else {
this.panel.classList.remove('minimized');
minimizeBtn.innerHTML = this.icons.minimize;
minimizeBtn.title = t('minimizeMinimize');
}
},
makeDraggable(panel, handle) {
let isDragging = false;
let startX, startY, startLeft, startTop;
handle.addEventListener('mousedown', (e) => {
// 锁定状态下不可拖动
if (this.isLocked) return;
// 点击最小化按钮或锁定按钮时不触发拖动
if (e.target.closest('.se-panel-minimize')) return;
if (e.target.closest('.se-panel-lock')) return;
if (e.target.closest('.se-lang-btn')) return;
isDragging = true;
startX = e.clientX;
startY = e.clientY;
startLeft = panel.offsetLeft;
startTop = panel.offsetTop;
panel.style.cursor = 'grabbing';
});
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
let newLeft = startLeft + dx;
let newTop = startTop + dy;
// 限制在浏览器显示区域内
const panelRect = panel.getBoundingClientRect();
const maxLeft = window.innerWidth - panelRect.width;
const maxTop = window.innerHeight - panelRect.height;
newLeft = Math.max(0, Math.min(newLeft, maxLeft));
newTop = Math.max(0, Math.min(newTop, maxTop));
panel.style.left = newLeft + 'px';
panel.style.top = newTop + 'px';
});
document.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
panel.style.cursor = '';
// 保存位置
SE.set('panelPos', {
left: panel.offsetLeft,
top: panel.offsetTop
});
}
});
},
openFontModal() {
if (this.modal) return;
const overlay = document.createElement('div');
overlay.className = 'se-font-modal-overlay';
const modal = document.createElement('div');
modal.className = 'se-font-modal';
modal.innerHTML = `
<div class="se-font-modal-header">
<span class="se-font-modal-title">${t('fontModalTitle')}</span>
<button class="se-font-modal-close">×</button>
</div>
<div class="se-font-modal-body">
<div class="se-font-loading">${t('fontLoading')}</div>
</div>
`;
overlay.appendChild(modal);
document.body.appendChild(overlay);
this.modal = overlay;
// 绑定关闭
modal.querySelector('.se-font-modal-close').addEventListener('click', () => {
this.closeFontModal();
});
overlay.addEventListener('click', (e) => {
if (e.target === overlay) this.closeFontModal();
});
// 加载字体列表
this.loadFonts();
},
closeFontModal() {
if (this.modal) {
this.modal.remove();
this.modal = null;
}
},
async loadFonts() {
try {
// 尝试使用 Font Access API 获取系统字体
// 使用 unsafeWindow 避免 Tampermonkey 沙箱导致的 Illegal invocation 错误
const win = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
if (win.queryLocalFonts) {
const fonts = await win.queryLocalFonts();
const fontSet = new Set();
fonts.forEach(font => fontSet.add(font.family));
this.fontList = Array.from(fontSet).sort();
} else {
// 备用:常用字体列表
this.fontList = this.getCommonFonts();
}
this.renderFontGrid();
} catch (err) {
console.warn('[StellarEnhancer] Font Access API failed:', err);
this.fontList = this.getCommonFonts();
this.renderFontGrid();
}
},
getCommonFonts() {
return [
'Arial', 'Arial Black', 'Calibri', 'Cambria', 'Candara',
'Comic Sans MS', 'Consolas', 'Constantia', 'Corbel', 'Courier New',
'Georgia', 'Impact', 'Lucida Console', 'Lucida Sans Unicode',
'Microsoft Sans Serif', 'Segoe UI', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Tahoma', 'Times New Roman', 'Trebuchet MS', 'Verdana',
'微软雅黑', 'Microsoft YaHei', '宋体', 'SimSun', '黑体', 'SimHei',
'新宋体', 'NSimSun', '仿宋', 'FangSong', '楷体', 'KaiTi',
'Helvetica', 'Helvetica Neue', 'Roboto', 'Open Sans', 'Lato',
'Montserrat', 'Poppins', 'Raleway', 'Ubuntu', 'Noto Sans',
'Source Sans Pro', 'Fira Sans', 'Inter', 'Work Sans',
'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Source Code Pro',
'Playfair Display', 'Merriweather', 'Lora', 'Libre Baskerville',
'Dancing Script', 'Pacifico', 'Great Vibes', 'Satisfy'
];
},
renderFontGrid() {
const body = this.modal.querySelector('.se-font-modal-body');
body.innerHTML = '<div class="se-font-grid"></div>';
const grid = body.querySelector('.se-font-grid');
this.fontList.forEach(fontName => {
const block = document.createElement('div');
block.className = 'se-font-block';
if (fontName === this.currentFont) {
block.classList.add('selected');
}
block.innerHTML = `
<div class="se-font-block-preview" style="font-family: '${fontName}', sans-serif;">Aa</div>
<div class="se-font-block-sample" style="font-family: '${fontName}', sans-serif;">${t('fontSample')}</div>
<div class="se-font-block-name">${fontName}</div>
`;
block.addEventListener('click', () => {
this.selectFont(fontName);
});
grid.appendChild(block);
});
},
selectFont(fontName) {
this.currentFont = fontName;
SE.set('selectedFont', fontName);
// 更新主面板显示
const fontNameEl = document.getElementById('se-current-font');
if (fontNameEl) {
fontNameEl.textContent = fontName;
fontNameEl.style.fontFamily = `'${fontName}'`;
}
// 应用字体到页面(使用 !important 强制覆盖)
this.applyFont();
// 启动定期检测锁定
this.startFontLock();
this.closeFontModal();
},
applyFont() {
if (!this.currentFont) return;
document.querySelector('body').style.setProperty(
'font-family',
`'${this.currentFont}'`,
'important'
);
},
startFontLock() {
// 清除已有的定时器
if (this.fontLockInterval) {
clearInterval(this.fontLockInterval);
}
// 每 500ms 检测并强制设置字体
this.fontLockInterval = setInterval(() => {
this.applyFont();
}, 500);
},
// ============================================================
// Coordinate Listener
// ============================================================
toggleCoordListener(enabled) {
this.isListeningCoord = enabled;
SE.set('listenCoordinate', enabled);
const coordDisplay = this.panel.querySelector('#se-coord-display');
if (enabled) {
coordDisplay.classList.add('active');
this.startCoordListener();
} else {
coordDisplay.classList.remove('active');
this.stopCoordListener();
}
},
startCoordListener() {
// 移除旧的事件监听(如果存在)
this.stopCoordListener();
// 绑定 visibilitychange 事件
this._visibilityHandler = () => {
if (document.visibilityState === 'visible') {
// 延迟执行,等待页面获得焦点
setTimeout(() => this.checkClipboardForCoord(), 100);
}
};
document.addEventListener('visibilitychange', this._visibilityHandler);
// 延迟执行首次检查,等待页面就绪
setTimeout(() => this.checkClipboardForCoord(), 500);
},
stopCoordListener() {
if (this._visibilityHandler) {
document.removeEventListener('visibilitychange', this._visibilityHandler);
this._visibilityHandler = null;
}
},
async checkClipboardForCoord() {
try {
// 检查文档是否有焦点,没有则尝试聚焦
if (!document.hasFocus()) {
window.focus();
if (!document.hasFocus()) {
setTimeout(() => this.checkClipboardForCoord(), 200);
return;
}
}
const clipboardItems = await navigator.clipboard.read();
for (const item of clipboardItems) {
if (item.types.includes('text/plain')) {
const blob = await item.getType('text/plain');
const text = await blob.text();
this.parseCoordFromText(text);
break;
}
}
} catch (err) {
if (err.name === 'NotAllowedError') {
setTimeout(() => this.checkClipboardForCoord(), 300);
}
}
},
parseCoordFromText(text) {
// 检查协议头 - 必须以协议开头
const protocol = '___STELLAR_ODYSSEY_SIM___';
if (!text.startsWith(protocol)) return;
// 提取 JSON 部分
const jsonText = text.substring(protocol.length).trim();
try {
// 处理类 JSON 格式:将 x: 转为 "x":
const normalizedJson = jsonText.replace(/(\w+):/g, '"$1":');
const data = JSON.parse(normalizedJson);
// 检查是否有 x 和 y 坐标
if (typeof data.x === 'number' && typeof data.y === 'number') {
this.displayCoordinate(data.x, data.y);
}
} catch (e) {
// JSON 解析失败,忽略
}
},
displayCoordinate(x, y) {
// 检查是否与上次坐标相同,避免重复输入
if (this.lastCoord && this.lastCoord.x === x && this.lastCoord.y === y) {
return;
}
const coordValue = this.panel.querySelector('#se-coord-value');
if (coordValue) {
coordValue.textContent = `X: ${x}, Y: ${y}`;
}
// 保存最后接收的坐标
this.lastCoord = { x, y };
},
toggleAutoNav(enabled) {
this.isAutoNav = enabled;
SE.set('autoNavigate', enabled);
},
// ============================================================
// Rune Monitor
// ============================================================
RUNE_DURATION_MS: 4 * 60 * 60 * 1000, // 4小时
toggleRuneMonitor(enabled) {
this.isRuneMonitor = enabled;
SE.set('runeMonitor', enabled);
const listEl = this.panel.querySelector('#se-rune-list');
if (enabled) {
listEl.classList.add('active');
this.startRuneMonitor();
} else {
listEl.classList.remove('active');
this.stopRuneMonitor();
this.updateRuneAlert();
}
},
startRuneMonitor() {
if (this._runeObserver) return; // 已在运行
// 从 GM 存储恢复符文数据(页面刷新/语言切换后恢复)
const savedRunes = SE.get('runes', []);
this.runes = savedRunes.filter(r => r.expiresAt > Date.now()); // 过滤已过期的
this.saveRunes(); // 清理后写回
this._runeObserver = new MutationObserver((mutations) => {
for (const mut of mutations) {
for (const node of mut.addedNodes) {
if (node.nodeType === 1 && node.getAttribute('role') === 'listitem') {
this.checkRuneInMessage(node);
}
}
}
});
// 持续尝试挂载到聊天列表,直到成功
// 如果列表 DOM 被销毁重建,也会自动重新挂载
const tryObserve = () => {
const chatList = document.querySelector('div.q-list.q-pr-md[role="list"]');
if (chatList) {
// 检查 target 是否需要重连(DOM 重建场景)
const obsTarget = this._runeObserver?.target;
if (!obsTarget || !document.contains(obsTarget) || obsTarget !== chatList) {
if (obsTarget) this._runeObserver.disconnect();
this._runeObserver.observe(chatList, { childList: true });
// 首次/重连后扫描已有条目
const items = chatList.querySelectorAll('[role="listitem"]');
items.forEach(item => this.checkRuneInMessage(item));
}
}
this._runeObserveTimer = setTimeout(tryObserve, 2000);
};
tryObserve();
// 定时更新倒计时和过期清理
this._runeTicker = setInterval(() => {
this.tickRunes();
}, 10000); // 每10秒检查过期
},
stopRuneMonitor() {
if (this._runeObserver) {
this._runeObserver.disconnect();
this._runeObserver = null;
}
if (this._runeObserveTimer) {
clearTimeout(this._runeObserveTimer);
this._runeObserveTimer = null;
}
if (this._runeTicker) {
clearInterval(this._runeTicker);
this._runeTicker = null;
}
this._runeObserverConnected = false;
this.runes = [];
this.saveRunes(); // 停止时清空存储
this.renderRuneList();
},
/** 暂停符文监听(语言切换等场景):仅停止 observer/timer,保留 runes 数据和已获取状态 */
pauseRuneMonitor() {
if (this._runeObserver) {
this._runeObserver.disconnect();
this._runeObserver = null;
}
if (this._runeObserveTimer) {
clearTimeout(this._runeObserveTimer);
this._runeObserveTimer = null;
}
if (this._runeTicker) {
clearInterval(this._runeTicker);
this._runeTicker = null;
}
this._runeObserverConnected = false;
// 不清空 runes、不写空存储、不渲染(即将 init 重建面板)
},
// ============================================================
// Rune Collection Tracker
// ============================================================
/**
* 4种符文类型 × 10个编号的收集记录
* 数据结构: { RingedDwarf: [false*10], BinaryStars: [false*10], BlackHole: [false*10], NeutronStar: [false*10] }
* 索引 0-9 对应符文编号 #1-#10
*/
RUNE_TYPES: ['RingedDwarf', 'BinaryStars', 'BlackHole', 'NeutronStar'],
RUNE_TYPE_COUNT: 10,
/** 从第一个卡片的 <use href="#runeXXX"> 推断符文类型(4种之一) */
inferRuneTypeFromHref(href) {
const spriteName = this.extractSpriteName(href);
if (!spriteName) return null;
const rest = spriteName.replace(/^rune/i, '');
if (!rest) return null;
for (const typeKey of this.RUNE_TYPES) {
if (rest.startsWith(typeKey)) return typeKey;
}
return null;
},
/** 游戏聊天中的符文类型显示名 → 内部存储 key 的映射(含空格变体) */
RUNE_DISPLAY_NAME_MAP: {
'Ringed Dwarf': 'RingedDwarf',
'RingedDwarf': 'RingedDwarf',
'Binary Stars': 'BinaryStars',
'BinaryStars': 'BinaryStars',
'Black Hole': 'BlackHole',
'BlackHole': 'BlackHole',
'Neutron Star': 'NeutronStar',
'NeutronStar': 'NeutronStar',
},
_isReadingCollection: false, // 是否处于"读取已收集"模式
_readTypesRecorded: null, // 本次读取中已记录的符文类型集合
_readContainerObserver: null, // 符文容器变动监听器
_readContainerPollTimer: null, // 备用轮询定时器(应对容器被销毁重建的场景)
_lastReadContainerEl: null, // 上次成功读取时的容器 DOM 引用(用于检测容器是否被替换)
_galaxyAutoTrackTimer: null, // galaxy 页面自动检测符文收集的轮询定时器
toggleRuneTracking(enabled) {
this.isRuneTracking = enabled;
SE.set('runeTracking', enabled);
const btn = this.panel.querySelector('#se-rune-read-btn');
if (btn) {
btn.style.display = enabled ? '' : 'none';
}
// 开启时初始化 collection(如果不存在)
if (enabled) {
let collection = SE.get('runeCollection', null);
if (!collection) {
collection = this.initEmptyCollection();
SE.set('runeCollection', collection);
}
// 启动 galaxy 页面自动检测
this.startGalaxyAutoTrack();
} else {
// 关闭追踪时,如果正在读取模式则退出
if (this._isReadingCollection) {
this.stopReadCollection();
}
// 停止 galaxy 页面自动检测
this.stopGalaxyAutoTrack();
}
this.updateTrackStatus();
},
/** 检查某个符文是否已在收集记录中标记为已获取 */
isCollectedInCollection(runeType, runeNum) {
const collection = SE.get('runeCollection', null);
if (!collection || !collection[runeType]) return false;
const numIdx = parseInt(runeNum, 10) - 1;
if (numIdx < 0 || numIdx >= this.RUNE_TYPE_COUNT) return false;
return !!collection[runeType][numIdx];
},
/** 将某个符文标记为已获取并写回 GM 存储 */
markCollectedInCollection(runeType, runeNum) {
const collection = SE.get('runeCollection', null);
if (!collection) return;
const numIdx = parseInt(runeNum, 10) - 1;
if (numIdx < 0 || numIdx >= this.RUNE_TYPE_COUNT) return;
if (collection[runeType] && !collection[runeType][numIdx]) {
collection[runeType][numIdx] = true;
SE.set('runeCollection', collection);
this.updateTrackStatus();
// 联动:从符文发现列表中过滤掉该已收集符文,并刷新 UI
if (this.isRuneMonitor && this.runes.length > 0) {
const numStr = String(runeNum);
const before = this.runes.length;
this.runes = this.runes.filter(r =>
!(r.runeType === runeType && String(r.runeNum) === numStr)
);
if (this.runes.length !== before) {
this.saveRunes();
this.renderRuneList();
this.updateRuneAlert();
}
}
}
},
/** 初始化空的 4×10 收集数据结构 */
initEmptyCollection() {
const collection = {};
for (const type of this.RUNE_TYPES) {
collection[type] = new Array(this.RUNE_TYPE_COUNT).fill(false);
}
return collection;
},
/** 更新 track 行的状态文字 */
updateTrackStatus() {
const btn = this.panel.querySelector('#se-rune-read-btn');
if (!btn) return;
// 如果正在读取模式,不覆盖按钮文本(由读取流程控制)
if (this._isReadingCollection) return;
const collection = SE.get('runeCollection', null);
if (!collection) {
btn.textContent = t('runeTrackBtnRead');
btn.classList.remove('se-active');
return;
}
// 统计已收集总数
let total = 0;
let collected = 0;
for (const type of this.RUNE_TYPES) {
if (collection[type]) {
total += collection[type].length;
collected += collection[type].filter(Boolean).length;
}
}
btn.textContent = `${collected}/${total}`;
},
// ============================================================
// Galaxy Auto-Track — 在 galaxy 页面自动检测已收集符文
// ============================================================
/** 启动 galaxy 页面自动检测轮询(1s 间隔) */
startGalaxyAutoTrack() {
if (this._galaxyAutoTrackTimer) return;
const tick = () => {
if (!this.isRuneTracking) {
this._galaxyAutoTrackTimer = null;
return;
}
this.scanGalaxyForCollectedRune();
this._galaxyAutoTrackTimer = setTimeout(tick, 1000);
};
this._galaxyAutoTrackTimer = setTimeout(tick, 1000);
},
/** 停止 galaxy 页面自动检测轮询 */
stopGalaxyAutoTrack() {
if (this._galaxyAutoTrackTimer) {
clearTimeout(this._galaxyAutoTrackTimer);
this._galaxyAutoTrackTimer = null;
}
},
/**
* 在 galaxy 页面扫描是否正在查看某个已收集符文的详情弹窗。
* 条件(同一父容器内同时满足):
* 1. div.q-mb-xs > svg > use[href*="#rune"] → 确定符文类型
* 2. div.q-pa-md 文本内容匹配 /\#(\d+)$/ → 提取编号
*/
scanGalaxyForCollectedRune() {
// 仅在 #/galaxy 路由下工作
if (!window.location.hash.includes('/galaxy')) return;
// 查找所有包含 use[href*="#rune"] 的 div.q-mb-xs
const useEls = document.querySelectorAll('div.q-mb-xs > svg > use');
let runeType = null;
let containerEl = null;
for (const useEl of useEls) {
const href = useEl.getAttribute('href') || useEl.getAttribute('xlink:href') || '';
if (!href.includes('#rune')) continue;
const type = this.inferRuneTypeFromHref(href);
if (!type) continue;
runeType = type;
// 向上找到 div.q-mb-xs,再找其父容器
containerEl = useEl.closest('div.q-mb-xs')?.parentElement || null;
break;
}
if (!runeType || !containerEl) return;
// 在同一父容器内寻找 div.q-pa-md,文本匹配「任意文字 #数字」
const labelDivs = containerEl.querySelectorAll('div.q-pa-md');
let runeNum = null;
for (const div of labelDivs) {
const text = (div.textContent || '').trim();
const m = text.match(/#(\d+)$/);
if (m) {
runeNum = parseInt(m[1], 10);
break;
}
}
if (!runeNum) return;
// 已找到符文类型和编号,尝试标记(markCollectedInCollection 内部已防重复)
this.markCollectedInCollection(runeType, runeNum);
},
/** 点击「读取已收集」按钮 → 进入读取模式 */
startReadCollection() {
if (this._isReadingCollection) return;
this._isReadingCollection = true;
this._readTypesRecorded = new Set();
// 按钮变为激活状态 + 等待文本
const btn = this.panel.querySelector('#se-rune-read-btn');
if (btn) {
btn.classList.add('se-active');
btn.textContent = t('runeTrackWaiting');
}
// 导航到 #/trophyroom
window.location.hash = '#/trophyroom';
// 等待符文容器出现
this.waitForRuneContainer();
},
/** 停止读取模式,恢复按钮状态 */
stopReadCollection() {
const recordedTypes = this._readTypesRecorded ? Array.from(this._readTypesRecorded) : [];
console.log(`[StellarEnhancer][Debug] stopReadCollection: types recorded=[${recordedTypes.join(', ')}]`);
this._isReadingCollection = false;
this._readTypesRecorded = null;
// 断开容器监听器
if (this._readContainerObserver) {
this._readContainerObserver.disconnect();
this._readContainerObserver = null;
}
// 停止备用轮询
if (this._readContainerPollTimer) {
clearTimeout(this._readContainerPollTimer);
this._readContainerPollTimer = null;
}
this._lastReadContainerEl = null;
// 恢复按钮样式
const btn = this.panel.querySelector('#se-rune-read-btn');
if (btn) {
btn.classList.remove('se-active');
this.updateTrackStatus();
}
},
/** 轮询等待符文容器 div.full-width.custom_card_grid_sm 出现 */
waitForRuneContainer() {
if (!this._isReadingCollection) return;
let attempts = 0;
const maxAttempts = 60;
const checkContainer = () => {
if (!this._isReadingCollection) return;
const container = document.querySelector('div.full-width.custom_card_grid_sm');
if (container) {
this.onRuneContainerReady(container);
return;
}
attempts++;
if (attempts < maxAttempts) {
setTimeout(checkContainer, 500);
} else {
console.warn('[StellarEnhancer][Debug] waitForRuneContainer: timeout after 30s');
this.stopReadCollection();
}
};
checkContainer();
},
/**
* 符文容器已就绪:
* 1. 解析所有子 <div> 节点
* 2. 从 <use href="#runeXXX"> 提取符文类型和编号
* 3. 从 disable 属性判断是否已收集
* 4. 写入 GM 存储
* 5. 更新按钮文本为 "已记录 N/4"
* 6. 设置 MutationObserver 监听后续变动
*/
onRuneContainerReady(container) {
if (!this._isReadingCollection || !container) return;
this.parseAndRecordRunes(container);
// 保存当前容器引用(用于检测容器是否被销毁重建)
this._lastReadContainerEl = container;
// 挂载 MutationObserver 监听容器变动
if (this._readContainerObserver) {
this._readContainerObserver.disconnect();
}
this._readContainerObserver = new MutationObserver(() => {
if (this._isReadingCollection && container.isConnected) {
this.parseAndRecordRunes(container);
} else {
this.stopReadCollection();
}
});
this._readContainerObserver.observe(container, { childList: true, subtree: true });
// 启动备用轮询定时器(应对游戏切换标签时销毁旧容器、创建新容器的场景)
if (this._readContainerPollTimer) {
clearTimeout(this._readContainerPollTimer);
}
const startPolling = () => {
if (!this._isReadingCollection) return;
this._readContainerPollTimer = setTimeout(() => {
if (!this._isReadingCollection) return;
const currentContainer = document.querySelector('div.full-width.custom_card_grid_sm');
if (currentContainer && currentContainer !== this._lastReadContainerEl) {
console.log('[StellarEnhancer][Debug] Poll: NEW rune container detected');
if (this._readContainerObserver) {
this._readContainerObserver.disconnect();
this._readContainerObserver = null;
}
this.onRuneContainerReady(currentContainer);
return;
}
startPolling();
}, 800);
};
startPolling();
},
/** 解析容器内子节点并记录收集状态
* - 从第一个卡片的 <use href="#runeXXX"> 判断符文类型(只需判断第一个,同组10个SVG相同)
* - 按子 div 的 DOM 顺序分配编号 #1~#10
* - 从 disable="true" 属性判断是否未收集
*/
parseAndRecordRunes(container) {
if (!container) return;
const children = Array.from(container.children).filter(
el => el.tagName === 'DIV' && el.getAttribute('tag') === 'label'
);
if (children.length === 0) return;
// 只看第一个卡片的 href 来判断整组是什么符文类型
const firstUse = children[0].querySelector('use[href]');
if (!firstUse) return;
const firstHref = firstUse.getAttribute('href');
const currentType = this.inferRuneTypeFromHref(firstHref);
if (!currentType) return;
// 读取当前 collection 数据
const collection = SE.get('runeCollection', null);
if (!collection || !collection[currentType]) return;
// 按子 div 顺序遍历,索引+1 即为符文编号 #1~#10
let recordedCount = 0;
children.forEach((child, idx) => {
const isMissing = child.getAttribute('disable') === 'true';
const isCollected = !isMissing;
collection[currentType][idx] = isCollected;
if (isCollected) recordedCount++;
});
// 写回存储
SE.set('runeCollection', collection);
// 标记该类型已记录
if (this._readTypesRecorded) {
this._readTypesRecorded.add(currentType);
}
console.log(`[StellarEnhancer][Debug] parseAndRecordRunes: type=${currentType}, collected=${recordedCount}/${children.length}`);
// 更新按钮文本
const btn = this.panel.querySelector('#se-rune-read-btn');
if (btn) {
const doneCount = this._readTypesRecorded ? this._readTypesRecorded.size : 0;
if (doneCount >= 4) {
btn.textContent = t('runeTrackDone');
setTimeout(() => this.stopReadCollection(), 1500);
} else {
btn.textContent = t('runeTrackRecorded')
.replace('{n}', doneCount)
.replace('{total}', '4');
}
}
},
/** 从 href 属性中提取 # 后面的 sprite 名称,如 "#runeBinaryStars" → "runeBinaryStars" */
extractSpriteName(href) {
if (!href) return '';
const match = href.match(/#(.+)$/);
return match ? match[1] : '';
},
// 输出新增聊天条目到 console(格式同 debugChatLog)
debugNewChatItem(item) {
const tsEl = item.querySelector('.tabular_nums');
const tsRaw = tsEl ? tsEl.textContent.trim() : '';
const ts = tsRaw.replace(/^\[|\]$/g, '');
const isSystem = !!item.querySelector('.text-blue.text-caption');
let type, sender, content;
if (isSystem) {
type = 'SYS';
const senderEl = item.querySelector('.text-blue.text-caption');
sender = senderEl ? senderEl.textContent.trim().replace(/\s*\|$/, '') : 'SERVER';
const channelEl = item.querySelector('.text-cyan-5');
const channel = channelEl ? channelEl.textContent.trim() : '';
const bodyEl = item.querySelector('.break-word_chat');
content = (channel + ' ' + (bodyEl ? bodyEl.textContent.trim() : '')).trim();
} else {
type = 'CHAT';
const nameEl = item.querySelector('[class*="character_name_color"]')
|| item.querySelector('.chaticon_space .text-weight-medium');
sender = nameEl ? nameEl.textContent.trim().replace(/\s*:\s*$/, '') : '(unknown)';
const bodyEl = item.querySelector('.break-word_chat');
content = bodyEl ? bodyEl.textContent.trim() : '';
}
console.log(
`[NEW] | [Type:${type}] | [Time:${ts}] | [Sender:${sender}] | [Content:${content}]`
);
},
// 匹配 "PlayerName found a RuneType rune #N at [x, y]"
checkRuneInMessage(item) {
// 符文发现只出现在系统消息中,跳过玩家聊天以消除干扰
if (!item.querySelector('.text-blue.text-caption')) return;
const bodyEl = item.querySelector('.break-word_chat');
if (!bodyEl) return;
const text = bodyEl.textContent.trim();
// 正则匹配符文发现消息
const runeMatch = text.match(/^(.+?)\s+found\s+a\s+(.+?)\s+rune\s+#(\d+)\s+at\s+\[(\d+),\s*(\d+)\]!/);
if (!runeMatch) {
return;
}
const [, player, runeTypeRaw, runeNum, x, y] = runeMatch;
// 将聊天中的显示名(如 "Ringed Dwarf")规范化为内部 key(如 "RingedDwarf")
const runeType = this.RUNE_DISPLAY_NAME_MAP[runeTypeRaw] || runeTypeRaw;
// 防重:检查是否已记录相同坐标+编号的符文
const dup = this.runes.some(r =>
r.runeNum === runeNum && r.x === x && r.y === y
);
if (dup) return;
// 联动:如果开启了收集追踪且该符文已收集过 → 静默跳过
if (this.isRuneTracking && this.isCollectedInCollection(runeType, runeNum)) {
return;
}
// 从聊天时间戳解析发现时间,格式: [DD/MM HH:MM]
const tsEl = item.querySelector('.tabular_nums');
const foundAt = this.parseChatTimestamp(tsEl);
this.runes.push({
player,
runeType,
runeNum,
x,
y,
foundAt: foundAt,
expiresAt: foundAt + this.RUNE_DURATION_MS,
collected: false,
});
// 持久化到 GM 存储
this.saveRunes();
// 弹出系统通知(仅有效期内的符文)
if (Date.now() < foundAt + this.RUNE_DURATION_MS) {
const i18n = _lang === 'zh' ? I18N.zh : I18N.en;
GM_notification({
title: i18n.runeNotifyTitle,
text: i18n.runeNotifyBody(runeType, runeNum, x, y, player),
timeout: 6000,
});
}
this.renderRuneList();
this.updateRuneAlert();
},
// 持久化符文数据到 GM 存储
saveRunes() {
SE.set('runes', this.runes);
},
tickRunes() {
const now = Date.now();
const before = this.runes.length;
// 只保留未过期的符文
this.runes = this.runes.filter(r => r.expiresAt > now);
// 数量变化时同步更新存储
if (this.runes.length !== before) {
this.saveRunes();
}
// 数量变化或还有剩余符文时刷新UI
if (this.runes.length !== before || this.runes.length > 0) {
this.renderRuneList();
this.updateRuneAlert();
}
},
getRuneRemaining(rune) {
const ms = rune.expiresAt - Date.now();
if (ms <= 0) return t('runeExpired');
const h = Math.floor(ms / 3600000);
const m = Math.floor((ms % 3600000) / 60000);
if (h > 0) return `${h}h${m}m`;
return `${m}m`;
},
renderRuneList() {
const el = this.panel.querySelector('#se-rune-list');
if (!el) return;
const now = Date.now();
// 渲染前再次过滤,确保只显示未过期的
const active = this.runes.filter(r => r.expiresAt > now);
if (active.length === 0) {
el.innerHTML = `<div style="color:#666;">${t('runeEmpty')}</div>`;
return;
}
// 按剩余时间排序(快过期的排前面)
const sorted = [...active].sort((a, b) => a.expiresAt - b.expiresAt);
el.innerHTML = sorted.map((r, i) => `
<div class="se-rune-item ${r.collected ? 'se-rune-collected' : ''}">
<div class="se-rune-item-body">
<span class="se-rune-type">${this.escapeHtml(r.runeType)} #${r.runeNum}</span>
<span class="se-rune-coord"> [${r.x}, ${r.y}]</span><br>
<span class="se-rune-player">${t('runeBy')}${this.escapeHtml(r.player)}</span>
<span class="se-rune-countdown">${t('runeRemaining')}${this.getRuneRemaining(r)}</span>
</div>
<label class="se-rune-cb-wrap">
<input type="checkbox" data-rune-idx="${this.runes.indexOf(r)}" ${r.collected ? 'checked' : ''} title="${t('runeCollectedTitle')}">
</label>
</div>
`).join('');
// 绑定 checkbox 事件
el.querySelectorAll('input[data-rune-idx]').forEach(cb => {
cb.addEventListener('change', (e) => {
const idx = parseInt(e.target.getAttribute('data-rune-idx'), 10);
if (idx >= 0 && idx < this.runes.length) {
this.runes[idx].collected = e.target.checked;
// 持久化已获取状态
this.saveRunes();
// 联动:同步到收集记录(如果开启了追踪)
if (this.isRuneTracking && e.target.checked) {
this.markCollectedInCollection(
this.runes[idx].runeType,
this.runes[idx].runeNum
);
}
this.renderRuneList();
this.updateRuneAlert();
}
});
});
},
updateRuneAlert() {
const titleEl = this.panel.querySelector('#se-panel-title');
const titleTextEl = this.panel.querySelector('.se-panel-title-text');
// 有效符文 = 未过期 + 未获取
const activeCount = this.runes.filter(r => r.expiresAt > Date.now() && !r.collected).length;
if (this.isRuneMonitor && activeCount > 0) {
titleEl.classList.add('se-rune-alert');
titleTextEl.classList.add('se-rune-alert-text');
titleTextEl.textContent = t('panelTitle');
} else {
titleEl.classList.remove('se-rune-alert');
titleTextEl.classList.remove('se-rune-alert-text');
titleTextEl.textContent = t('panelTitle');
}
},
escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
},
// 解析聊天时间戳 "[DD/MM HH:MM]" 为 Date.now() 格式时间戳
// 聊天年份默认为当前年
parseChatTimestamp(tsEl) {
if (!tsEl) return Date.now(); // 解析失败回退到当前时间
const raw = tsEl.textContent.trim();
// 格式: [21/04 03:32]
const m = raw.match(/\[(\d{2})\/(\d{2})\s+(\d{2}):(\d{2})\]/);
if (!m) return Date.now();
const [, day, month, hour, min] = m;
const now = new Date();
const year = now.getFullYear();
const parsed = new Date(year, parseInt(month, 10) - 1, parseInt(day, 10), parseInt(hour, 10), parseInt(min, 10));
// 如果解析出的时间比当前晚超过12小时(跨年边界),减1年
if (parsed.getTime() > now.getTime() + 12 * 3600000) {
parsed.setFullYear(year - 1);
}
return parsed.getTime();
},
// ============================================================
// Debug
// ============================================================
debugChatLog() {
const list = document.querySelector('div.q-list.q-pr-md[role="list"]');
if (!list) {
console.warn('[StellarEnhancer] Chat list not found');
return;
}
const items = list.querySelectorAll('[role="listitem"]');
console.log(`[StellarEnhancer] Chat log: ${items.length} entries`);
console.log('---');
items.forEach((item, idx) => {
// 时间戳
const tsEl = item.querySelector('.tabular_nums');
const tsRaw = tsEl ? tsEl.textContent.trim() : '';
// 去掉方括号: [20/04 20:53] → 20/04 20:53
const ts = tsRaw.replace(/^\[|\]$/g, '');
// 判断类型:系统消息有 .text-blue.text-caption(SERVER |),玩家聊天没有
const isSystem = !!item.querySelector('.text-blue.text-caption');
let type, sender, content;
if (isSystem) {
type = 'SYS';
// SERVER |
const senderEl = item.querySelector('.text-blue.text-caption');
sender = senderEl ? senderEl.textContent.trim().replace(/\s*\|$/, '') : 'SERVER';
// Global:
const channelEl = item.querySelector('.text-cyan-5');
const channel = channelEl ? channelEl.textContent.trim() : '';
// 消息正文
const bodyEl = item.querySelector('.break-word_chat');
content = (channel + ' ' + (bodyEl ? bodyEl.textContent.trim() : '')).trim();
} else {
type = 'CHAT';
// 玩家名
const nameEl = item.querySelector('[class*="character_name_color"]')
|| item.querySelector('.chaticon_space .text-weight-medium');
sender = nameEl ? nameEl.textContent.trim().replace(/\s*:\s*$/, '') : '(unknown)';
// 消息正文
const bodyEl = item.querySelector('.break-word_chat');
content = bodyEl ? bodyEl.textContent.trim() : '';
}
console.log(
`[Index:${idx}] | [Type:${type}] | [Time:${ts}] | [Sender:${sender}] | [Content:${content}]`
);
});
},
async performAutoNavigate(x, y) {
// 如果正在导航中,跳过
if (this.isNavigating) {
return;
}
// 获取当前域名,构建galaxy页面URL
const currentHost = window.location.host;
const galaxyUrl = `https://${currentHost}/#/galaxy`;
// 如果当前不在galaxy页面,先跳转
if (!window.location.href.includes('/#/galaxy')) {
window.location.href = galaxyUrl;
// 页面跳转不会导致脚本重置,继续执行等待canvas
}
// 设置导航锁
this.isNavigating = true;
// 等待canvas初始化完成
await this.waitForCanvas();
// 获取输入框并填入坐标
const inputs = document.querySelectorAll('main input');
if (inputs.length >= 2) {
inputs[0].value = x;
inputs[0].dispatchEvent(new Event('input', { bubbles: true }));
inputs[1].value = y;
inputs[1].dispatchEvent(new Event('input', { bubbles: true }));
// 清空剪贴板,避免重复导航
this.clearClipboard();
}
// 释放导航锁
this.isNavigating = false;
},
waitForCanvas() {
return new Promise((resolve) => {
// 如果canvas已经初始化过,直接继续
if (this.canvasReady) {
resolve();
return;
}
const checkCanvas = () => {
const canvas = document.querySelector('div.galaxy-canvas-container canvas');
if (canvas) {
this.waitFirstFrame(canvas).then(() => {
this._markCanvasReady();
resolve();
});
} else {
setTimeout(checkCanvas, 100);
}
};
checkCanvas();
});
},
waitFirstFrame(canvas) {
return new Promise(resolve => {
// 尝试获取 WebGL 上下文
const gl = canvas.getContext('webgl') || canvas.getContext('webgl2');
if (gl) {
this._hookWebGL(gl, resolve, canvas);
return;
}
// 尝试获取 2D 上下文
const ctx2d = canvas.getContext('2d');
if (ctx2d) {
this._hookCanvas2D(ctx2d, resolve, canvas);
return;
}
this._delayResolve(resolve, canvas);
});
},
_markCanvasReady() {
this.canvasReady = true;
},
_hookWebGL(gl, resolve, canvas) {
let done = false;
const proto = Object.getPrototypeOf(gl);
const origDrawArrays = proto.drawArrays;
const origDrawElements = proto.drawElements;
const cleanup = () => {
proto.drawArrays = origDrawArrays;
proto.drawElements = origDrawElements;
};
const onFirstFrame = () => {
if (!done) {
done = true;
cleanup();
this._delayResolve(resolve, canvas);
}
};
proto.drawArrays = function(...args) {
onFirstFrame();
return origDrawArrays.apply(this, args);
};
proto.drawElements = function(...args) {
onFirstFrame();
return origDrawElements.apply(this, args);
};
},
_hookCanvas2D(ctx, resolve, canvas) {
let done = false;
const proto = Object.getPrototypeOf(ctx);
const methodsToHook = ['fillRect', 'strokeRect', 'fillText', 'strokeText', 'drawImage', 'fill', 'stroke'];
const originals = {};
const cleanup = () => {
methodsToHook.forEach(method => {
if (originals[method]) {
proto[method] = originals[method];
}
});
};
const onFirstFrame = () => {
if (!done) {
done = true;
cleanup();
this._delayResolve(resolve, canvas);
}
};
methodsToHook.forEach(method => {
if (proto[method]) {
originals[method] = proto[method];
proto[method] = function(...args) {
onFirstFrame();
return originals[method].apply(this, args);
};
}
});
},
_delayResolve(resolve, canvas) {
// 确保延迟1秒后resolve
setTimeout(() => {
resolve(canvas);
}, 1000);
},
async clearClipboard() {
try {
await navigator.clipboard.writeText('');
} catch (err) {
// 忽略
}
}
};
// ============================================================
// Init
// ============================================================
function init() {
StellarEnhancer.init();
// 注册油猴菜单命令
GM_registerMenuCommand(t('gmResetPos'), () => {
SE.set('panelPos', null);
SE.set('panelLocked', false);
if (StellarEnhancer.panel) {
StellarEnhancer.panel.style.left = '0px';
StellarEnhancer.panel.style.top = '0px';
StellarEnhancer.panel.style.visibility = 'visible';
}
alert(t('gmResetPosAlert'));
});
}
// ============================================================
// Boot - 等待页面完全加载后再初始化
// ============================================================
function waitForPageLoad() {
// 如果页面已经加载完成,直接初始化
if (document.readyState === 'complete') {
init();
return;
}
// 等待 window.load 事件(页面所有资源加载完成)
window.addEventListener('load', () => {
// 稍微延迟确保 toolbar 已渲染
setTimeout(init, 100);
});
}
waitForPageLoad();
})();