// wallpaper-engine.js // Wallpaper Engine 网页壁纸 API 模拟器(浏览器调试环境) // 完全兼容 Wallpaper Engine 网页壁纸 API 规范 // // 使用方式:在 index.html 中以 // 优先加载本脚本即可。若检测到真实的 Wallpaper Engine 环境则自动跳过。 /* ============================================================ * 工具 * ============================================================ */ const WEUtils = { /** Wallpaper Engine 颜色值(归一化浮点 "r g b")→ HEX */ colorToHex(value) { if (typeof value !== "string") return "#000000"; const parts = value.trim().split(/\s+/).map(Number); if (parts.length < 3 || parts.some(isNaN)) return "#000000"; return ( "#" + parts .slice(0, 3) .map((n) => { const v = Math.max(0, Math.min(255, Math.round(n * 255))); return v.toString(16).padStart(2, "0"); }) .join("") ); }, /** HEX → Wallpaper Engine 颜色值 */ hexToColor(hex) { if (typeof hex !== "string" || hex[0] !== "#" || hex.length < 7) return "0 0 0"; const r = parseInt(hex.slice(1, 3), 16) / 255; const g = parseInt(hex.slice(3, 5), 16) / 255; const b = parseInt(hex.slice(5, 7), 16) / 255; return `${r} ${g} ${b}`; }, /** Wallpaper Engine 文件路径 → 可用 URL */ filePathToUrl(path) { if (!path) return ""; if (path.startsWith("file:///")) return path; if (path.startsWith("file://")) return "file:///" + path.slice(7); return path; }, /** 简单 debounce */ debounce(fn, wait = 150) { let timer = null; return function (...args) { if (timer) clearTimeout(timer); timer = setTimeout(() => fn.apply(this, args), wait); }; }, }; /* ============================================================ * 条件表达式求值器 * 支持:value == x / value != x / && / || / ! / () * ============================================================ */ class ConditionEvaluator { constructor(getPropertyValue) { this.getPropertyValue = getPropertyValue; this._cache = new Map(); } /** * @param {string} condition 例如 "show_author_info.value == true" * @returns {boolean} */ evaluate(condition) { if (!condition || typeof condition !== "string") return true; try { // 把 "xxx.value" 替换为对应的字面量 const expr = condition.replace(/([A-Za-z_$][\w$]*)\.value/g, (_, key) => { const v = this.getPropertyValue(key); if (typeof v === "string") return JSON.stringify(v); if (v === undefined || v === null) return "undefined"; if (typeof v === "boolean" || typeof v === "number") return String(v); return JSON.stringify(v); }); // 使用 Function 仅在纯布尔表达式上求值 // 该表达式来源可信(project.json 由壁纸作者提供),且已做 key 白名单替换 const fn = this._cache.get(expr) || new Function(`"use strict"; return Boolean(${expr});`); this._cache.set(expr, fn); return fn(); } catch (err) { console.warn("[WallpaperEngine] 条件求值失败:", condition, err); return true; } } } /* ============================================================ * 主模拟器 * ============================================================ */ class WallpaperEngineSimulator { constructor() { this.isInitialized = false; this.isPanelVisible = false; this.projectData = null; /** @type {Record} */ this.properties = {}; /** @type {Record} 属性原始定义 */ this.propertyCache = {}; this.propertyListeners = []; this.generalListeners = []; this.audioListeners = []; this.userDataPath = ""; // DOM 元素缓存 this.panelEl = null; this.styleEl = null; this.fieldEls = new Map(); // 鼠标状态 this._mouse = { isOverPanel: false, hideTimer: null, EDGE_ZONE: 60, HIDE_DELAY: 400, }; this.conditionEvaluator = new ConditionEvaluator( (key) => this.properties[key]?.value, ); this.initialize(); } /* -------------------------------------------------------- * 初始化 * -------------------------------------------------------- */ async initialize() { try { await this.loadProjectData(); this.initializeProperties(); this.initializeAPI(); this.setupMouseListeners(); this.isInitialized = true; console.log( "[WallpaperEngine] 模拟器已初始化,共", Object.keys(this.properties).length, "个属性", ); } catch (err) { console.error("[WallpaperEngine] 初始化失败:", err); } } async loadProjectData() { try { const res = await fetch("./project.json", { cache: "no-cache" }); if (!res.ok) throw new Error(`HTTP ${res.status}`); this.projectData = await res.json(); console.log("[WallpaperEngine] project.json 已加载"); } catch (err) { console.warn( "[WallpaperEngine] 无法加载 project.json,使用内置默认配置:", err, ); this.projectData = this.getFallbackProjectData(); } } getFallbackProjectData() { return { title: "壁纸设置", general: { properties: {} }, }; } initializeProperties() { const props = this.projectData?.general?.properties; if (!props) { console.warn("[WallpaperEngine] project.json 缺少 general.properties"); return; } for (const [key, def] of Object.entries(props)) { this.propertyCache[key] = def; this.properties[key] = { value: def.value !== undefined ? def.value : this.getDefaultValue(def.type), }; } } getDefaultValue(type) { switch (type) { case "bool": return false; case "combo": return ""; case "slider": return 0; case "textinput": return ""; case "color": return "0 0 0"; case "text": return ""; default: return null; } } /* -------------------------------------------------------- * 鼠标监听 * -------------------------------------------------------- */ setupMouseListeners() { document.addEventListener("mousemove", (e) => { const { EDGE_ZONE } = this._mouse; const nearRightEdge = e.clientX >= window.innerWidth - EDGE_ZONE; if (nearRightEdge && !this.isPanelVisible) { this.showSettingsPanel(); } else if ( !nearRightEdge && !this._mouse.isOverPanel && this.isPanelVisible ) { this._scheduleHide(); } }); // 悬停在面板内部时取消隐藏 document.addEventListener("mouseover", (e) => { const inside = this.panelEl && this.panelEl.contains(e.target); if (inside !== this._mouse.isOverPanel) { this._mouse.isOverPanel = inside; if (inside) this._cancelHide(); } }); // 鼠标离开窗口 document.addEventListener("mouseleave", () => { this._mouse.isOverPanel = false; this._scheduleHide(200); }); // ESC 关闭 document.addEventListener("keydown", (e) => { if (e.key === "Escape" && this.isPanelVisible) { this.hideSettingsPanel(); } }); } _scheduleHide(delay = this._mouse.HIDE_DELAY) { this._cancelHide(); this._mouse.hideTimer = setTimeout(() => { if (!this._mouse.isOverPanel) this.hideSettingsPanel(); }, delay); } _cancelHide() { if (this._mouse.hideTimer) { clearTimeout(this._mouse.hideTimer); this._mouse.hideTimer = null; } } /* -------------------------------------------------------- * 面板显示 / 隐藏 * -------------------------------------------------------- */ showSettingsPanel() { if (!this.projectData?.general?.properties) return; // 单例:首次创建 if (!this.panelEl) { this.createSettingsPanel(); } if (!this.isPanelVisible) { this.isPanelVisible = true; // 强制重排以触发 transition void this.panelEl.offsetWidth; this.panelEl.classList.add("we-panel--visible"); } } hideSettingsPanel() { if (!this.isPanelVisible || !this.panelEl) return; this.isPanelVisible = false; this.panelEl.classList.remove("we-panel--visible"); } /* -------------------------------------------------------- * 面板构造 * -------------------------------------------------------- */ createSettingsPanel() { if (this.styleEl) this.styleEl.remove(); this.styleEl = document.createElement("style"); this.styleEl.id = "wallpaper-settings-style"; this.styleEl.textContent = this.getPanelStyles(); document.head.appendChild(this.styleEl); const panel = document.createElement("div"); panel.id = "wallpaper-settings-panel"; panel.className = "we-panel"; // 头部 const header = document.createElement("div"); header.className = "we-panel__header"; header.innerHTML = ` `; header.querySelector(".we-panel__title").textContent = this.projectData.title || "壁纸设置"; header.querySelector(".we-panel__close").addEventListener("click", () => { this.hideSettingsPanel(); }); // 内容 const content = document.createElement("div"); content.className = "we-panel__content"; const sorted = this.getSortedProperties(); this.fieldEls.clear(); for (const { key, def } of sorted) { const row = this.createFieldRow(key, def); if (row) { content.appendChild(row); // 缓存可交互的控件 const ctrl = row.querySelector("[data-we-key]"); if (ctrl) this.fieldEls.set(key, ctrl); } } panel.appendChild(header); panel.appendChild(content); document.body.appendChild(panel); this.panelEl = panel; // 首次渲染后套用条件显示 this.refreshConditionalVisibility(); } /** * 生成单个属性控件的外层容器 */ createFieldRow(key, def) { if (!def) return null; const row = document.createElement("div"); row.className = "we-field"; row.id = `we-field-${key}`; row.dataset.condition = def.condition || ""; const label = document.createElement("label"); label.className = "we-field__label"; // 支持 text 属性里的
label.innerHTML = def.text || key; switch (def.type) { case "text": return this.buildTextRow(row, key, def); case "bool": return this.buildBoolRow(row, key, def, label); case "combo": return this.buildComboRow(row, key, def, label); case "slider": return this.buildSliderRow(row, key, def, label); case "textinput": return this.buildTextInputRow(row, key, def, label); case "color": return this.buildColorRow(row, key, def, label); default: return this.buildTextRow(row, key, def); } } buildTextRow(row, key, def) { const text = document.createElement("div"); text.className = "we-field__text"; // Wallpaper Engine 支持在 text 属性内嵌 HTML text.innerHTML = def.text || ""; row.appendChild(text); return row; } buildBoolRow(row, key, def, label) { label.htmlFor = key; row.appendChild(label); const wrap = document.createElement("label"); wrap.className = "we-switch"; wrap.innerHTML = ` `; const input = wrap.querySelector("input"); input.checked = Boolean(this.properties[key]?.value); input.addEventListener("change", () => { this.updateProperty(key, input.checked); }); row.appendChild(wrap); return row; } buildComboRow(row, key, def, label) { label.htmlFor = key; row.appendChild(label); const select = document.createElement("select"); select.className = "we-select"; select.id = key; select.dataset.weKey = key; const cur = this.properties[key]?.value; for (const opt of def.options || []) { const o = document.createElement("option"); o.value = opt.value; o.textContent = opt.label ?? opt.value; if (opt.value === cur) o.selected = true; select.appendChild(o); } select.addEventListener("change", () => { this.updateProperty(key, select.value); }); row.appendChild(select); return row; } buildSliderRow(row, key, def, label) { label.htmlFor = key; row.appendChild(label); const min = def.min ?? 0; const max = def.max ?? 1; const step = def.step ?? 0.01; const slider = document.createElement("input"); slider.type = "range"; slider.className = "we-slider"; slider.id = key; slider.dataset.weKey = key; slider.min = min; slider.max = max; slider.step = step; slider.value = this.properties[key]?.value ?? min; const valueLabel = document.createElement("div"); valueLabel.className = "we-slider__value"; valueLabel.textContent = this._formatSliderValue(slider.value, min, max); // 实时更新显示 + 触发属性更新(debounce 写入属性,避免频繁触发监听器) const pushValue = WEUtils.debounce(() => { this.updateProperty(key, parseFloat(slider.value)); }, 60); slider.addEventListener("input", () => { valueLabel.textContent = this._formatSliderValue(slider.value, min, max); pushValue(); }); // 松开后强制刷新一次 slider.addEventListener("change", () => { this.updateProperty(key, parseFloat(slider.value)); }); row.appendChild(slider); row.appendChild(valueLabel); return row; } _formatSliderValue(v, min, max) { const n = parseFloat(v); if (max - min === 1 && min === 0) { return `${Math.round(n * 100)}%`; } return `${n}`; } buildTextInputRow(row, key, def, label) { label.htmlFor = key; row.appendChild(label); const input = document.createElement("input"); input.type = "text"; input.className = "we-input"; input.id = key; input.dataset.weKey = key; input.value = this.properties[key]?.value ?? ""; input.placeholder = def.placeholder || ""; const pushValue = WEUtils.debounce(() => { this.updateProperty(key, input.value); }, 300); input.addEventListener("input", pushValue); input.addEventListener("change", () => { this.updateProperty(key, input.value); }); row.appendChild(input); return row; } buildColorRow(row, key, def, label) { label.htmlFor = key; row.appendChild(label); const input = document.createElement("input"); input.type = "color"; input.className = "we-color"; input.id = key; input.dataset.weKey = key; input.value = WEUtils.colorToHex(this.properties[key]?.value || "0 0 0"); input.addEventListener("input", () => { this.updateProperty(key, WEUtils.hexToColor(input.value)); }); row.appendChild(input); return row; } /* -------------------------------------------------------- * 属性排序 / 条件刷新 * -------------------------------------------------------- */ getSortedProperties() { return Object.keys(this.propertyCache) .map((key) => ({ key, def: this.propertyCache[key], order: this.propertyCache[key]?.order ?? 0, })) .sort((a, b) => a.order - b.order); } refreshConditionalVisibility() { for (const [key, def] of Object.entries(this.propertyCache)) { const row = document.getElementById(`we-field-${key}`); if (!row || !def.condition) continue; const visible = this.conditionEvaluator.evaluate(def.condition); row.classList.toggle("we-field--hidden", !visible); } } /* -------------------------------------------------------- * 属性更新 / 通知 * -------------------------------------------------------- */ updateProperty(name, value) { if (!this.properties[name]) return; if (this.properties[name].value === value) return; this.properties[name].value = value; // 更新主题色 if (name === "schemecolor" && this.styleEl) { const hex = WEUtils.colorToHex(value); document.documentElement.style.setProperty("--we-theme-color", hex); } // 刷新条件显示 this.refreshConditionalVisibility(); // 通知监听器 this.notifyPropertyListeners(name, value); } notifyPropertyListeners(name, value) { const payload = { [name]: { value } }; for (const listener of this.propertyListeners) { try { listener.applyUserProperties?.(payload); } catch (err) { console.error("[WallpaperEngine] applyUserProperties 抛错:", err); } } } /* -------------------------------------------------------- * 样式 * -------------------------------------------------------- */ getPanelStyles() { const themeHex = WEUtils.colorToHex( this.properties.schemecolor?.value || "0.388 0.149 0.62", ); return ` :root { --we-theme-color: ${themeHex}; } #wallpaper-settings-panel.we-panel { position: fixed; top: 0; right: 0; width: 320px; height: 100vh; transform: translateX(100%); transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1); background: rgba(22, 22, 30, 0.92); backdrop-filter: blur(14px) saturate(140%); -webkit-backdrop-filter: blur(14px) saturate(140%); border-left: 1px solid rgba(255, 255, 255, 0.08); box-shadow: -8px 0 32px rgba(0, 0, 0, 0.45); color: #f0f0f5; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; font-size: 14px; z-index: 999999; overflow-y: auto; overflow-x: hidden; will-change: transform; } #wallpaper-settings-panel.we-panel--visible { transform: translateX(0); } /* 头部 */ .we-panel__header { position: sticky; top: 0; display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; background: rgba(0, 0, 0, 0.25); border-bottom: 1px solid rgba(255, 255, 255, 0.06); backdrop-filter: blur(10px); z-index: 2; } .we-panel__title { font-size: 15px; font-weight: 600; letter-spacing: 0.3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .we-panel__close { width: 28px; height: 28px; border: none; background: transparent; color: #f0f0f5; font-size: 22px; line-height: 1; border-radius: 50%; cursor: pointer; transition: background 0.15s, transform 0.15s; } .we-panel__close:hover { background: rgba(255,255,255,0.1); transform: rotate(90deg); } /* 内容区 */ .we-panel__content { padding: 16px 20px 40px; } /* 每个属性块 */ .we-field { margin-bottom: 18px; } .we-field--hidden { display: none !important; } .we-field__label { display: block; margin-bottom: 8px; font-size: 13px; font-weight: 500; color: #e6e6ee; line-height: 1.4; } .we-field__text { font-size: 12px; color: #9a9aa8; line-height: 1.55; } .we-field__text a { color: var(--we-theme-color); text-decoration: none; transition: opacity 0.15s; } .we-field__text a:hover { opacity: 0.8; text-decoration: underline; } .we-field__text small { font-size: 12px; } .we-field__text ul, .we-field__text ol { margin: 4px 0; padding-left: 20px; } .we-field__text li { margin: 3px 0; } .we-field__text p { margin: 4px 0; } .we-field__text code { background: rgba(255,255,255,0.08); padding: 1px 5px; border-radius: 3px; font-size: 11px; } /* 开关 */ .we-switch { position: relative; display: inline-block; width: 44px; height: 24px; cursor: pointer; vertical-align: middle; float: right; margin-top: -30px; } .we-switch input { opacity: 0; width: 0; height: 0; } .we-switch__slider { position: absolute; inset: 0; background: rgba(255,255,255,0.16); border-radius: 24px; transition: background 0.25s; } .we-switch__slider::before { content: ""; position: absolute; left: 3px; top: 3px; width: 18px; height: 18px; background: #fff; border-radius: 50%; transition: transform 0.25s; box-shadow: 0 1px 3px rgba(0,0,0,0.35); } .we-switch input:checked + .we-switch__slider { background: var(--we-theme-color); } .we-switch input:checked + .we-switch__slider::before { transform: translateX(20px); } /* 下拉框 / 输入框 */ .we-select, .we-input { width: 100%; box-sizing: border-box; padding: 9px 11px; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12); border-radius: 6px; color: #f0f0f5; font-size: 13px; font-family: inherit; outline: none; transition: border-color 0.15s, background 0.15s; } .we-select:hover, .we-input:hover { background: rgba(255,255,255,0.09); } .we-select:focus, .we-input:focus { border-color: var(--we-theme-color); background: rgba(255,255,255,0.1); } .we-select option { background: #1b1b25; color: #f0f0f5; } /* 滑块 */ .we-slider { width: 100%; margin: 6px 0 2px; -webkit-appearance: none; appearance: none; height: 4px; border-radius: 2px; background: rgba(255,255,255,0.14); outline: none; cursor: pointer; } .we-slider::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 16px; height: 16px; border-radius: 50%; background: var(--we-theme-color); border: 2px solid #fff; box-shadow: 0 1px 4px rgba(0,0,0,0.4); cursor: grab; transition: transform 0.12s; } .we-slider::-webkit-slider-thumb:hover { transform: scale(1.15); } .we-slider::-webkit-slider-thumb:active { cursor: grabbing; } .we-slider::-moz-range-thumb { width: 14px; height: 14px; border-radius: 50%; background: var(--we-theme-color); border: 2px solid #fff; box-shadow: 0 1px 4px rgba(0,0,0,0.4); cursor: grab; } .we-slider__value { text-align: right; font-size: 12px; color: #9a9aa8; font-variant-numeric: tabular-nums; } /* 颜色选择器 */ .we-color { width: 100%; height: 36px; padding: 0; border: 1px solid rgba(255,255,255,0.12); border-radius: 6px; background: transparent; cursor: pointer; -webkit-appearance: none; appearance: none; } .we-color::-webkit-color-swatch-wrapper { padding: 2px; } .we-color::-webkit-color-swatch { border: none; border-radius: 4px; } /* 滚动条 */ .we-panel::-webkit-scrollbar { width: 6px; } .we-panel::-webkit-scrollbar-track { background: transparent; } .we-panel::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.18); border-radius: 3px; } .we-panel::-webkit-scrollbar-thumb:hover { background: var(--we-theme-color); } `; } /* -------------------------------------------------------- * Wallpaper Engine 全局 API 注入 * -------------------------------------------------------- */ initializeAPI() { // ---------- wallpaperPropertyListener ---------- if (!window.wallpaperPropertyListener) { window.wallpaperPropertyListener = { applyGeneralProperties: (properties) => { this._emit("applyGeneralProperties", properties); }, applyUserProperties: (properties) => { this._emit("applyUserProperties", properties); }, }; } // ---------- wallpaperRegisterListener ---------- if (!window.wallpaperRegisterListener) { window.wallpaperRegisterListener = (listener) => { if (!listener || typeof listener !== "object") return; if (typeof listener.applyGeneralProperties === "function") { this.generalListeners.push(listener); } if (typeof listener.applyUserProperties === "function") { this.propertyListeners.push(listener); } if (typeof listener.applyAudioProperties === "function") { this.audioListeners.push(listener); } }; } // ---------- 其它 API 占位 ---------- if (!window.wallpaperGetUserDataPath) { window.wallpaperGetUserDataPath = () => this.userDataPath; } if (!window.wallpaperRequestFile) { // Wallpaper Engine 原生:请求文件(steam 云 / 用户数据) // 浏览器无法访问,仅返回 null 占位 window.wallpaperRequestFile = () => { console.warn( "[WallpaperEngine] wallpaperRequestFile 在浏览器环境中不可用", ); return null; }; } if (!window.wallpaperRegisterAudioListener) { window.wallpaperRegisterAudioListener = (cb) => { if (typeof cb === "function") this.audioListeners.push({ applyAudioProperties: cb }); }; } if (!window.wallpaperGetWallpaperAuthor) { window.wallpaperGetWallpaperAuthor = () => this.projectData?.general?.properties?.author?.value || ""; } if (!window.wallpaperEngine) { window.wallpaperEngine = { setProperty: (name, value) => this.updateProperty(name, value), getProperty: (name) => this.properties[name]?.value, getUserDataPath: () => this.userDataPath, getProjectData: () => this.projectData, }; } } _emit(method, payload) { const lists = method === "applyGeneralProperties" ? this.generalListeners : this.propertyListeners; for (const listener of lists) { try { listener[method]?.(payload); } catch (err) { console.error(`[WallpaperEngine] ${method} 抛错:`, err); } } } /* -------------------------------------------------------- * 模拟初始属性推送(浏览器调试用) * Wallpaper Engine 在壁纸加载完成后,会先调用 applyGeneralProperties, * 再逐条调用 applyUserProperties。 * -------------------------------------------------------- */ simulateWallpaperEngine() { const run = () => { // 等待 index.js 中的 wallpaperRegisterListener 注册完成 setTimeout(() => { // 1. 通用属性 this._emit("applyGeneralProperties", { fps: 60 }); // 2. 逐条推送用户属性(模拟 Wallpaper Engine 行为) for (const [key, prop] of Object.entries(this.properties)) { this._emit("applyUserProperties", { [key]: { value: prop.value } }); } console.log("[WallpaperEngine] 已推送初始属性到监听器"); }, 100); }; if (document.readyState === "complete") run(); else window.addEventListener("load", run, { once: true }); } } /* ============================================================ * 环境检测 & 启动 * ============================================================ */ function isWallpaperEngineEnvironment() { return ( typeof window.wallpaperRegisterListener === "function" && typeof window.wallpaperPropertyListener === "object" && window.wallpaperPropertyListener !== null ); } function bootstrap() { if (isWallpaperEngineEnvironment()) { console.log( "[WallpaperEngine] 检测到原生 Wallpaper Engine 环境,跳过模拟器", ); return; } const simulator = new WallpaperEngineSimulator(); const check = setInterval(() => { if (simulator.isInitialized) { clearInterval(check); simulator.simulateWallpaperEngine(); } }, 50); // 兜底:5 秒后仍未初始化则强制停止轮询 setTimeout(() => clearInterval(check), 5000); // 便于调试 window.__WE_SIMULATOR__ = simulator; } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", bootstrap, { once: true }); } else { bootstrap(); } export default { isWallpaperEngineEnvironment, WallpaperEngineSimulator, WEUtils, };