diff --git a/dist/scripts/audio-controller.js b/dist/scripts/audio-controller.js index 3680ab9..96c77b0 100644 --- a/dist/scripts/audio-controller.js +++ b/dist/scripts/audio-controller.js @@ -1,64 +1,64 @@ +// 背景音乐。 +// +// 两条交互约定: +// 1. 音源与音量是「用户级覆盖」:切换壁纸不得把用户自定义的音源/音量清回预设默认值。 +// 2. 音源相同的设置是 no-op:否则两档壁纸共用同一首曲子时,一切换就把音乐从头开始放。 export default class AudioController { constructor(audioId, options = {}) { this.audio = document.getElementById(audioId); - this.audio.src = options.source; - this.audio.volume = options.volume; - this.audio.loop = true; this.isPlaying = false; - this.isChangingSource = false; - this.previousSource = null; + this.retryTimer = undefined; + this.audio.loop = true; + + if (options.source) this.setSource(options.source); + if (options.volume !== undefined) this.setVolume(options.volume); } + + // audio.src 会被解析成绝对 URL;预设里的 source 也是绝对 URL(preset.js 用 import.meta.url 推导) + get source() { + return this.audio.src || ""; + } + + setSource(source) { + if (!source) return; + if (this.source === new URL(source, document.baseURI).href) return; + + const wasPlaying = this.isPlaying; + this.audio.src = source; + this.audio.load(); + if (wasPlaying) this.play(); + } + + setVolume(volume) { + const value = Math.max(0, Math.min(1, Number(volume))); + this.audio.volume = Number.isFinite(value) ? value : 1; + + if (this.audio.volume <= 0) this.pause(); + else if (!this.isPlaying) this.play(); + } + play() { - if (this.isPlaying) { - return; - } - if (this.isChangingSource) { - return; - } - if (this.audio) { - setTimeout(() => { + if (this.isPlaying) return; + + this.audio.play().then( + () => { this.isPlaying = true; - this.audio.play().catch((error) => { - this.isPlaying = false; - this.changeSource(this.previousSource); - }); - }, 200); - } + }, + (error) => { + this.isPlaying = false; + // 只重试一次:自动播放被拒时重试多少次都没用,重试只是覆盖"音频尚未就绪" + if (this.retryTimer === undefined) { + this.retryTimer = setTimeout(() => { + this.retryTimer = undefined; + this.play(); + }, 500); + } + }, + ); } + pause() { this.audio.pause(); this.isPlaying = false; } - changeSource(newSource) { - if (this.isChangingSource) { - return; - } - if (this.audio) { - this.isChangingSource = true; - this.pause(); - this.previousSource = this.audio.src; - this.audio.src = newSource; - this.isChangingSource = false; - this.play(); - } - } - changeVolume(newVolume) { - const clampedVolume = Math.max(0, Math.min(1, newVolume)); - this.audio.volume = clampedVolume; - this.changeStateByVolume(); - } - changeStateByVolume() { - const currentVolume = this.audio.volume; - if (currentVolume > 0 && !this.isPlaying) { - this.play(); - } else if (currentVolume <= 0 && this.isPlaying) { - this.pause(); - } - } - changeOptions(newOptions) { - const newSource = newOptions.source; - const newVolume = newOptions.volume; - this.changeSource(newSource); - this.changeVolume(newVolume); - } } diff --git a/dist/scripts/background-controller.js b/dist/scripts/background-controller.js index f0bc02c..05119df 100644 --- a/dist/scripts/background-controller.js +++ b/dist/scripts/background-controller.js @@ -1,14 +1,13 @@ +// 背景图:只在真的换了图时写一次样式。 export default class BackgroundController { - constructor(backgroundImage) { - this.backgroundImage = backgroundImage; + constructor() { + this.appliedImage = ""; } - setImage(backgroundImage = this.backgroundImage) { - this.backgroundImage = backgroundImage; - document.getElementsByTagName( - "body" - )[0].style.backgroundImage = `url(${backgroundImage})`; - } - changeImage(newBackgroundImage) { - this.setImage(newBackgroundImage); + + setImage(backgroundImage) { + if (!backgroundImage || backgroundImage === this.appliedImage) return; + this.appliedImage = backgroundImage; + // 用引号包住,避免路径里出现空格等字符时被 CSS 解析成多个值 + document.body.style.backgroundImage = `url("${backgroundImage}")`; } } diff --git a/dist/scripts/index.js b/dist/scripts/index.js index 556ce68..642302a 100644 --- a/dist/scripts/index.js +++ b/dist/scripts/index.js @@ -1,87 +1,86 @@ -import Presets from "./presets.js"; - -import PresetController from "./preset-controller.js"; -import BackgroundController from "./background-controller.js"; -import SpineController from "./spine-controller.js"; -import AudioController from "./audio-controller.js"; - -let presetController = new PresetController(Presets); - -let { backgroundImage, spineConfig, audioOptions } = - presetController.set("xilian"); - -let backgroundController = new BackgroundController(backgroundImage); -let spineController = new SpineController("spine-container", spineConfig); -let audioController = new AudioController("background-music", audioOptions); - -let currentAudioSource = ""; -let currentAudioVolume = 0; - -window.addEventListener("load", function () { - backgroundController.setImage(); - spineController.create(); - audioController.play(); -}); - -window.addEventListener("resize", () => { - spineController.adjustCanvas(); -}); - -window.wallpaperRegisterListener = { - applyGeneralProperties: function (properties) { - if (properties.fps) { - // wallpaperSettings.fps = properties.fps; - } - }, - applyUserProperties: function (properties) { - if (properties.preset) { - const preset = properties.preset.value; - let { backgroundImage, spineConfig, audioOptions } = - presetController.set(preset); - backgroundController.changeImage(backgroundImage); - spineController.changeConfig(spineConfig); - audioController.changeOptions(audioOptions); - } - if (properties.audio_file) { - const audio_file = properties.audio_file.value; - currentAudioSource = audio_file; - audioController.changeSource(audio_file); - } - if (properties.audio_volume) { - const audio_volume = properties.audio_volume.value; - currentAudioVolume = audio_volume; - audioController.changeVolume(audio_volume); - } - }, -}; - -window.wallpaperPropertyListener = { - applyGeneralProperties: function (properties) { - if (properties.fps) { - // wallpaperSettings.fps = properties.fps; - } - }, - applyUserProperties: function (properties) { - if (properties.preset) { - const preset = properties.preset.value; - let { backgroundImage, spineConfig, audioOptions } = presetController.set( - preset, - currentAudioSource, - currentAudioVolume - ); - backgroundController.changeImage(backgroundImage); - spineController.changeConfig(spineConfig); - audioController.changeOptions(audioOptions); - } - if (properties.audio_file) { - const audio_file = properties.audio_file.value; - currentAudioSource = audio_file; - audioController.changeSource(audio_file); - } - if (properties.audio_volume) { - const audio_volume = properties.audio_volume.value; - currentAudioVolume = audio_volume; - audioController.changeVolume(audio_volume); - } - }, -}; +import Presets from "./presets.js"; + +import PresetController from "./preset-controller.js"; +import BackgroundController from "./background-controller.js"; +import SpineController from "./spine-controller.js"; +import AudioController from "./audio-controller.js"; + +// 与 project.json 里属性的默认值保持一致 +const DEFAULT_PRESET_ID = "xilian"; +const DEFAULT_VOLUME = 0.5; + +const presets = new PresetController(Presets); + +const backgroundController = new BackgroundController(); +const spineController = new SpineController("spine-container"); +const audioController = new AudioController("background-music"); + +// 唯一的真相来源。WE 可能在 window.load 之前就下发用户属性,所以这里只记录状态; +// 渲染时再决定"能不能立刻应用"——只有播放器需要等到 load 之后才存在。 +const state = { + ready: false, + presetId: "", + audioSource: "", // 空字符串 = 用当前预设的默认音源 + audioVolume: DEFAULT_VOLUME, +}; + +let appliedPresetId = ""; + +function render() { + const presetId = state.presetId || DEFAULT_PRESET_ID; + const preset = presets.get(presetId); + if (!preset) return; + + // 只有壁纸真的换了才碰背景与骨架。否则拖动音量滑块都会重建播放器 + // (重建 = 重新下载 3.4 MB 骨架与全部贴图)。 + if (appliedPresetId !== presetId) { + appliedPresetId = presetId; + backgroundController.setImage(preset.backgroundImage); + spineController.setConfig(preset.spineConfig); // 播放器还不存在时只是记下来 + } + + // 用户自定义音源优先;用户没给就用当前壁纸的默认音源 + audioController.setSource(state.audioSource || preset.audioOptions.source); + audioController.setVolume(state.audioVolume); + if (state.ready) audioController.play(); +} + +window.addEventListener("load", function () { + state.ready = true; + render(); // 先把状态落到各控制器(此时播放器尚不存在,只写配置) + spineController.create(); // 再用写好的配置创建播放器 +}); + +// 必须在模块顶层注册:属性事件可能在 window.load 之前到达 +window.wallpaperPropertyListener = { + applyUserProperties: function (properties) { + // 先写状态:无条件记录,与播放器是否存在、是否加载完成无关 + if (properties.preset) { + const presetId = properties.preset.value; + if (presets.has(presetId)) { + state.presetId = presetId; + } else if (presetId) { + console.warn(`忽略未知的预设 id:${presetId}`); + } + } + + if (properties.audio_file) { + state.audioSource = String(properties.audio_file.value || "").trim(); + } + + if (properties.audio_volume) { + const volume = Number(properties.audio_volume.value); + if (Number.isFinite(volume)) { + state.audioVolume = Math.max(0, Math.min(1, volume)); + } + } + + render(); + }, + + applyGeneralProperties: function (properties) { + if (properties.fps) { + // fps 限制在第 05 步接入:WE 不替壁纸限流,需要自己按 properties.fps 节流绘制 + } + }, +}; diff --git a/dist/scripts/preset-controller.js b/dist/scripts/preset-controller.js index fe3db66..d56102a 100644 --- a/dist/scripts/preset-controller.js +++ b/dist/scripts/preset-controller.js @@ -1,35 +1,25 @@ +// 预设查表:id → 预设数据。 +// 预设数据是 ES module 里的模块级常量,返回深拷贝,避免调用方改坏它。 +// 注意 id 必须与 project.json 里 combo 选项的 value 一致(见 docs/adr/0001)。 export default class PresetController { - constructor(presetStore) { - this.presetStore = presetStore; - this.previousPreset = ""; - this.currentPreset = ""; + constructor(presets) { + this.presets = presets; + this.currentId = ""; } - getName() { - return Object.keys(this.presetStore); + + get ids() { + return Object.keys(this.presets); } - set(newPreset, currentAudioSource, currentAudioVolume) { - let newPresetData = JSON.parse(JSON.stringify(this.presetStore[newPreset])); - if (this.previousPreset) { - if ( - currentAudioSource && - this.presetStore[this.previousPreset].audioOptions.source !== - currentAudioSource - ) { - newPresetData.audioOptions.source = currentAudioSource; - } - if ( - currentAudioVolume || - (currentAudioVolume === 0 && - this.presetStore[this.previousPreset].audioOptions.volume !== - currentAudioVolume) - ) { - newPresetData.audioOptions.volume = currentAudioVolume; - } - } - if (this.currentPreset) { - this.previousPreset = this.currentPreset; - } - this.currentPreset = newPreset; - return newPresetData; + + has(id) { + return Boolean(this.presets[id]); + } + + // 未知 id 返回 null,由调用方决定是保持现状还是回退——不在这里悄悄改成别的预设 + get(id) { + const preset = this.presets[id]; + if (!preset) return null; + this.currentId = id; + return JSON.parse(JSON.stringify(preset)); } } diff --git a/dist/scripts/spine-controller.js b/dist/scripts/spine-controller.js index 9f40ba3..25ff0b2 100644 --- a/dist/scripts/spine-controller.js +++ b/dist/scripts/spine-controller.js @@ -1,60 +1,50 @@ +// 负责把「当前预设的 spineConfig」应用到 Spine 播放器上。 +// +// 核心约定:写入与应用分开。WE 可能在 window.load 之前就下发用户属性,那时播放器还不存在; +// 旧实现把配置赋值也塞在 `if (this.player)` 里,于是那次配置被静默丢弃, +// 结果是"背景与音乐已切到新壁纸、立绘还是旧壁纸"的混合状态(已实测复现)。 +// 现在 setConfig() 无论如何都记下配置,create() 用记下的配置创建。 export default class SpineController { - constructor(containerId, config = {}) { + constructor(containerId, baseConfig = {}) { this.containerId = containerId; - this.config = { + this.baseConfig = { alpha: true, premultipliedAlpha: false, showControls: false, showLoading: false, - ...config, + ...baseConfig, }; - this.previousConfig = {}; - + this.presetConfig = {}; this.container = document.getElementById(containerId); this.player = undefined; } + // 只写状态:与播放器是否存在无关,随时可调用 + setConfig(presetConfig = {}) { + this.presetConfig = presetConfig; + if (this.player) this.rebuild(); + } + create() { - if (!this.player) { - const containerId = this.containerId; - const config = this.config; - this.player = new spine.SpinePlayer(containerId, config); - } + if (this.player) return; + // 每次都用 baseConfig + 当前 presetConfig 现算,避免上一份预设的键残留 + this.player = new spine.SpinePlayer(this.containerId, { + ...this.baseConfig, + ...this.presetConfig, + }); + } + + // 换骨架没有捷径,只能销毁重建 + rebuild() { + this.dispose(); + this.create(); } dispose() { - if (this.player) { - const container = this.container; - this.player.dispose(); - container.innerHTML = ""; - this.player = undefined; - } - } - - adjustCanvas() { - if (this.player) { - const container = this.container; - const canvas = container.querySelector("canvas"); - if (canvas) { - const rect = container.getBoundingClientRect(); - canvas.style.width = "100%"; - canvas.style.height = "100%"; - canvas.width = rect.width; - canvas.height = rect.height; - } - } - } - - changeConfig(newConfig = {}) { - if (this.player) { - this.previousConfig = this.config; - this.config = { - ...this.config, - ...newConfig, - }; - this.dispose(); - this.create(); - } + if (!this.player) return; + this.player.dispose(); + this.container.innerHTML = ""; + this.player = undefined; } } diff --git a/dist/scripts/wallpaper-engine.js b/dist/scripts/wallpaper-engine.js new file mode 100644 index 0000000..54a95cc --- /dev/null +++ b/dist/scripts/wallpaper-engine.js @@ -0,0 +1,974 @@ +// 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, +}; diff --git a/tools/diff.mjs b/tools/diff.mjs index 052063c..5b384bb 100644 --- a/tools/diff.mjs +++ b/tools/diff.mjs @@ -1,12 +1,19 @@ -// 两张 PNG 的数值对拍:PSNR / SSIM / 平均亮度差(0-255)。 -// 用法:node tools/diff.mjs [diff-out.png] -// 判读:平均差 < 1 视为肉眼无差异;1-3 为细微;> 8 通常意味着构图/内容变了。 +// 两张图的数值对拍:PSNR / SSIM / 平均绝对差 / 最大绝对差(均为 0-255 尺度)。 +// 用法:node tools/diff.mjs [diff-out.png] +// +// 判读: +// mean|diff| ≈ 0 且 max|diff| = 0 → 逐像素相同 +// mean|diff| 极小 但 max|diff| 很大 → 只有零星像素变了(多半是高对比边缘的抗锯齿抖动) +// mean|diff| 明显变大 → 整体变化(构图/内容/色调) +// +// 注意:ffmpeg 对很小的数值会用科学计数法(YAVG=4.96721e-05), +// 解析时必须带上指数,否则会把 0.00005 误读成 4.97——这个坑踩过一次。 import { spawn } from "node:child_process"; const [a, b, outPng] = process.argv.slice(2); if (!a || !b) { - console.error("usage: node tools/diff.mjs [diff-out.png]"); + console.error("usage: node tools/diff.mjs [diff-out.png]"); process.exit(2); } @@ -20,28 +27,31 @@ function ff(args) { }); } -const psnr = await ff(["-i", a, "-i", b, "-lavfi", "psnr", "-f", "null", "-"]); -const ssim = await ff(["-i", a, "-i", b, "-lavfi", "ssim", "-f", "null", "-"]); -const yavg = await ff([ - "-i", a, "-i", b, - "-lavfi", "blend=all_mode=difference,signalstats,metadata=print:key=lavfi.signalstats.YAVG", - "-f", "null", "-", -]); - +const NUM = "([-+0-9.eE]+)"; const num = (text, re) => { const m = text.match(re); return m ? Number(m[1]) : NaN; }; -const yavgs = [...yavg.matchAll(/YAVG=([\d.]+)/g)].map((m) => Number(m[1])); +const psnr = await ff(["-i", a, "-i", b, "-lavfi", "psnr", "-f", "null", "-"]); +const ssim = await ff(["-i", a, "-i", b, "-lavfi", "ssim", "-f", "null", "-"]); +const stats = await ff([ + "-i", a, "-i", b, + "-lavfi", "blend=all_mode=difference,format=gray,signalstats,metadata=print", + "-f", "null", "-", +]); -const report = { - psnr_avg: /average:inf/.test(psnr) ? Infinity : num(psnr, /average:([\d.]+)/), - ssim_all: num(ssim, /All:([\d.]+)/), - mean_abs_diff: yavgs.length ? yavgs[yavgs.length - 1] : NaN, -}; +const frames = [...stats.matchAll(/frame:(\d+)/g)].length; +const yavg = num(stats, new RegExp(`YAVG=${NUM}`)); +const ymax = num(stats, new RegExp(`YMAX=${NUM}`)); +const inf = /average:inf/.test(psnr); -console.log(`psnr=${report.psnr_avg} dB ssim=${report.ssim_all} mean|diff|=${report.mean_abs_diff}/255`); +console.log( + `psnr=${inf ? "∞" : num(psnr, new RegExp(`average:${NUM}`))} dB ` + + `ssim=${num(ssim, new RegExp(`All:${NUM}`))} ` + + `mean|diff|=${yavg}/255 max|diff|=${ymax}` + + (frames > 1 ? ` [${frames} 帧,取首帧]` : ""), +); if (outPng) { await ff(["-y", "-i", a, "-i", b, "-lavfi", "blend=all_mode=difference", "-frames:v", "1", outPng]); diff --git a/tools/serve.mjs b/tools/serve.mjs index 6033851..373ed4f 100644 --- a/tools/serve.mjs +++ b/tools/serve.mjs @@ -88,6 +88,8 @@ const DRIVER = (q) => `