fix: 初始化竞态、用户级覆盖语义与死代码(重构四个控制器)

初始化竞态(已实证):旧 changeConfig() 整个函数体都在 if (this.player) 里,含配置赋值,
WE 在 window.load 之前下发属性时被静默丢弃,导致背景/音乐已切到新壁纸、立绘还是旧的。
改为「状态先写、应用后置」:index.js 用单一 state 记录(presetId/audioSource/audioVolume),
render() 决定能否立刻应用;setConfig() 无条件记录配置,create() 用记下的配置创建。

- 删除 index.js 里重复的 wallpaperRegisterListener(该 API 在本机 WE 不存在)
- 删除 adjustCanvas():播放器每帧自己做 renderer.resize(1) 且已注册 resize 监听,
  而它用 getBoundingClientRect() 写 canvas.width 却不乘 DPR,属于有害的重复实现
- 用户级音源/音量覆盖:切壁纸不再清回预设默认值(旧 PresetController 的启发式已移除)
- 同源设置改为 no-op:否则两档壁纸共用同一首曲子时一切换就从头播放
- 只在壁纸真的变了才重建播放器,否则拖动音量滑块会触发重新下载 3.6MB 骨架+贴图
- 未知 preset id 保持现状并告警,不再静默回退

验证:属性在 DOMContentLoaded 下发时玩家动画正确为 animation(kv37);
用户自定义音源 custom-track.mp3 与音量 0.2 在切壁纸后完整保留;
重发同一预设时音乐从 1.3s 连续到 1.9s 未重启;未知 id 不崩。
对拍:与重构前同强冻结设置 8 批次 6 配置逐像素完全相同(差异 0)。
This commit is contained in:
Shuery committed 2026-09-19 19:53:15 +08:00
1 parent a391021041
commit 4662856963
8 files changed
+1209 -240

No files matched your search

+51 -51
View File
@@ -1,64 +1,64 @@
// 背景音乐。
//
// 两条交互约定:
// 1. 音源与音量是「用户级覆盖」:切换壁纸不得把用户自定义的音源/音量清回预设默认值。
// 2. 音源相同的设置是 no-op:否则两档壁纸共用同一首曲子时,一切换就把音乐从头开始放。
export default class AudioController { export default class AudioController {
constructor(audioId, options = {}) { constructor(audioId, options = {}) {
this.audio = document.getElementById(audioId); this.audio = document.getElementById(audioId);
this.audio.src = options.source;
this.audio.volume = options.volume;
this.audio.loop = true;
this.isPlaying = false; this.isPlaying = false;
this.isChangingSource = false; this.retryTimer = undefined;
this.previousSource = null; 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() { play() {
if (this.isPlaying) { if (this.isPlaying) return;
return;
} this.audio.play().then(
if (this.isChangingSource) { () => {
return;
}
if (this.audio) {
setTimeout(() => {
this.isPlaying = true; this.isPlaying = true;
this.audio.play().catch((error) => { },
this.isPlaying = false; (error) => {
this.changeSource(this.previousSource); this.isPlaying = false;
}); // 只重试一次:自动播放被拒时重试多少次都没用,重试只是覆盖"音频尚未就绪"
}, 200); if (this.retryTimer === undefined) {
} this.retryTimer = setTimeout(() => {
this.retryTimer = undefined;
this.play();
}, 500);
}
},
);
} }
pause() { pause() {
this.audio.pause(); this.audio.pause();
this.isPlaying = false; 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);
}
} }
+9 -10
View File
@@ -1,14 +1,13 @@
// 背景图:只在真的换了图时写一次样式。
export default class BackgroundController { export default class BackgroundController {
constructor(backgroundImage) { constructor() {
this.backgroundImage = backgroundImage; this.appliedImage = "";
} }
setImage(backgroundImage = this.backgroundImage) {
this.backgroundImage = backgroundImage; setImage(backgroundImage) {
document.getElementsByTagName( if (!backgroundImage || backgroundImage === this.appliedImage) return;
"body" this.appliedImage = backgroundImage;
)[0].style.backgroundImage = `url(${backgroundImage})`; // 用引号包住,避免路径里出现空格等字符时被 CSS 解析成多个值
} document.body.style.backgroundImage = `url("${backgroundImage}")`;
changeImage(newBackgroundImage) {
this.setImage(newBackgroundImage);
} }
} }
+86 -87
View File
@@ -1,87 +1,86 @@
import Presets from "./presets.js"; import Presets from "./presets.js";
import PresetController from "./preset-controller.js"; import PresetController from "./preset-controller.js";
import BackgroundController from "./background-controller.js"; import BackgroundController from "./background-controller.js";
import SpineController from "./spine-controller.js"; import SpineController from "./spine-controller.js";
import AudioController from "./audio-controller.js"; import AudioController from "./audio-controller.js";
let presetController = new PresetController(Presets); // 与 project.json 里属性的默认值保持一致
const DEFAULT_PRESET_ID = "xilian";
let { backgroundImage, spineConfig, audioOptions } = const DEFAULT_VOLUME = 0.5;
presetController.set("xilian");
const presets = new PresetController(Presets);
let backgroundController = new BackgroundController(backgroundImage);
let spineController = new SpineController("spine-container", spineConfig); const backgroundController = new BackgroundController();
let audioController = new AudioController("background-music", audioOptions); const spineController = new SpineController("spine-container");
const audioController = new AudioController("background-music");
let currentAudioSource = "";
let currentAudioVolume = 0; // 唯一的真相来源。WE 可能在 window.load 之前就下发用户属性,所以这里只记录状态;
// 渲染时再决定"能不能立刻应用"——只有播放器需要等到 load 之后才存在。
window.addEventListener("load", function () { const state = {
backgroundController.setImage(); ready: false,
spineController.create(); presetId: "",
audioController.play(); audioSource: "", // 空字符串 = 用当前预设的默认音源
}); audioVolume: DEFAULT_VOLUME,
};
window.addEventListener("resize", () => {
spineController.adjustCanvas(); let appliedPresetId = "";
});
function render() {
window.wallpaperRegisterListener = { const presetId = state.presetId || DEFAULT_PRESET_ID;
applyGeneralProperties: function (properties) { const preset = presets.get(presetId);
if (properties.fps) { if (!preset) return;
// wallpaperSettings.fps = properties.fps;
} // 只有壁纸真的换了才碰背景与骨架。否则拖动音量滑块都会重建播放器
}, // (重建 = 重新下载 3.4 MB 骨架与全部贴图)。
applyUserProperties: function (properties) { if (appliedPresetId !== presetId) {
if (properties.preset) { appliedPresetId = presetId;
const preset = properties.preset.value; backgroundController.setImage(preset.backgroundImage);
let { backgroundImage, spineConfig, audioOptions } = spineController.setConfig(preset.spineConfig); // 播放器还不存在时只是记下来
presetController.set(preset); }
backgroundController.changeImage(backgroundImage);
spineController.changeConfig(spineConfig); // 用户自定义音源优先;用户没给就用当前壁纸的默认音源
audioController.changeOptions(audioOptions); audioController.setSource(state.audioSource || preset.audioOptions.source);
} audioController.setVolume(state.audioVolume);
if (properties.audio_file) { if (state.ready) audioController.play();
const audio_file = properties.audio_file.value; }
currentAudioSource = audio_file;
audioController.changeSource(audio_file); window.addEventListener("load", function () {
} state.ready = true;
if (properties.audio_volume) { render(); // 先把状态落到各控制器(此时播放器尚不存在,只写配置)
const audio_volume = properties.audio_volume.value; spineController.create(); // 再用写好的配置创建播放器
currentAudioVolume = audio_volume; });
audioController.changeVolume(audio_volume);
} // 必须在模块顶层注册:属性事件可能在 window.load 之前到达
}, window.wallpaperPropertyListener = {
}; applyUserProperties: function (properties) {
// 先写状态:无条件记录,与播放器是否存在、是否加载完成无关
window.wallpaperPropertyListener = { if (properties.preset) {
applyGeneralProperties: function (properties) { const presetId = properties.preset.value;
if (properties.fps) { if (presets.has(presetId)) {
// wallpaperSettings.fps = properties.fps; state.presetId = presetId;
} } else if (presetId) {
}, console.warn(`忽略未知的预设 id:${presetId}`);
applyUserProperties: function (properties) { }
if (properties.preset) { }
const preset = properties.preset.value;
let { backgroundImage, spineConfig, audioOptions } = presetController.set( if (properties.audio_file) {
preset, state.audioSource = String(properties.audio_file.value || "").trim();
currentAudioSource, }
currentAudioVolume
); if (properties.audio_volume) {
backgroundController.changeImage(backgroundImage); const volume = Number(properties.audio_volume.value);
spineController.changeConfig(spineConfig); if (Number.isFinite(volume)) {
audioController.changeOptions(audioOptions); state.audioVolume = Math.max(0, Math.min(1, volume));
} }
if (properties.audio_file) { }
const audio_file = properties.audio_file.value;
currentAudioSource = audio_file; render();
audioController.changeSource(audio_file); },
}
if (properties.audio_volume) { applyGeneralProperties: function (properties) {
const audio_volume = properties.audio_volume.value; if (properties.fps) {
currentAudioVolume = audio_volume; // fps 限制在第 05 步接入:WE 不替壁纸限流,需要自己按 properties.fps 节流绘制
audioController.changeVolume(audio_volume); }
} },
}, };
};
+20 -30
View File
@@ -1,35 +1,25 @@
// 预设查表:id → 预设数据。
// 预设数据是 ES module 里的模块级常量,返回深拷贝,避免调用方改坏它。
// 注意 id 必须与 project.json 里 combo 选项的 value 一致(见 docs/adr/0001)。
export default class PresetController { export default class PresetController {
constructor(presetStore) { constructor(presets) {
this.presetStore = presetStore; this.presets = presets;
this.previousPreset = ""; this.currentId = "";
this.currentPreset = "";
} }
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])); has(id) {
if (this.previousPreset) { return Boolean(this.presets[id]);
if ( }
currentAudioSource &&
this.presetStore[this.previousPreset].audioOptions.source !== // 未知 id 返回 null,由调用方决定是保持现状还是回退——不在这里悄悄改成别的预设
currentAudioSource get(id) {
) { const preset = this.presets[id];
newPresetData.audioOptions.source = currentAudioSource; if (!preset) return null;
} this.currentId = id;
if ( return JSON.parse(JSON.stringify(preset));
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;
} }
} }
+32 -42
View File
@@ -1,60 +1,50 @@
// 负责把「当前预设的 spineConfig」应用到 Spine 播放器上。
//
// 核心约定:写入与应用分开。WE 可能在 window.load 之前就下发用户属性,那时播放器还不存在;
// 旧实现把配置赋值也塞在 `if (this.player)` 里,于是那次配置被静默丢弃,
// 结果是"背景与音乐已切到新壁纸、立绘还是旧壁纸"的混合状态(已实测复现)。
// 现在 setConfig() 无论如何都记下配置,create() 用记下的配置创建。
export default class SpineController { export default class SpineController {
constructor(containerId, config = {}) { constructor(containerId, baseConfig = {}) {
this.containerId = containerId; this.containerId = containerId;
this.config = { this.baseConfig = {
alpha: true, alpha: true,
premultipliedAlpha: false, premultipliedAlpha: false,
showControls: false, showControls: false,
showLoading: false, showLoading: false,
...config, ...baseConfig,
}; };
this.previousConfig = {}; this.presetConfig = {};
this.container = document.getElementById(containerId); this.container = document.getElementById(containerId);
this.player = undefined; this.player = undefined;
} }
// 只写状态:与播放器是否存在无关,随时可调用
setConfig(presetConfig = {}) {
this.presetConfig = presetConfig;
if (this.player) this.rebuild();
}
create() { create() {
if (!this.player) { if (this.player) return;
const containerId = this.containerId; // 每次都用 baseConfig + 当前 presetConfig 现算,避免上一份预设的键残留
const config = this.config; this.player = new spine.SpinePlayer(this.containerId, {
this.player = new spine.SpinePlayer(containerId, config); ...this.baseConfig,
} ...this.presetConfig,
});
}
// 换骨架没有捷径,只能销毁重建
rebuild() {
this.dispose();
this.create();
} }
dispose() { dispose() {
if (this.player) { if (!this.player) return;
const container = this.container; this.player.dispose();
this.player.dispose(); this.container.innerHTML = "";
container.innerHTML = ""; this.player = undefined;
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();
}
} }
} }
+974
View File
@@ -0,0 +1,974 @@
// wallpaper-engine.js
// Wallpaper Engine 网页壁纸 API 模拟器(浏览器调试环境)
// 完全兼容 Wallpaper Engine 网页壁纸 API 规范
//
// 使用方式:在 index.html 中以 <script type="module" src="scripts/wallpaper-engine.js"></script>
// 优先加载本脚本即可。若检测到真实的 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<string, {value:any}>} */
this.properties = {};
/** @type {Record<string, any>} 属性原始定义 */
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 = `
<span class="we-panel__title"></span>
<button class="we-panel__close" type="button" aria-label="关闭">×</button>
`;
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 属性里的 <br/>
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 = `
<input type="checkbox" data-we-key="${key}" id="${key}">
<span class="we-switch__slider"></span>
`;
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,
};
+29 -19
View File
@@ -1,12 +1,19 @@
// 两张 PNG 的数值对拍:PSNR / SSIM / 平均亮度差(0-255)。 // 两张图的数值对拍:PSNR / SSIM / 平均绝对差 / 最大绝对差(均为 0-255 尺度)。
// 用法:node tools/diff.mjs <a.png> <b.png> [diff-out.png] // 用法:node tools/diff.mjs <a> <b> [diff-out.png]
// 判读:平均差 < 1 视为肉眼无差异;1-3 为细微;> 8 通常意味着构图/内容变了。 //
// 判读:
// 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"; import { spawn } from "node:child_process";
const [a, b, outPng] = process.argv.slice(2); const [a, b, outPng] = process.argv.slice(2);
if (!a || !b) { if (!a || !b) {
console.error("usage: node tools/diff.mjs <a.png> <b.png> [diff-out.png]"); console.error("usage: node tools/diff.mjs <a> <b> [diff-out.png]");
process.exit(2); process.exit(2);
} }
@@ -20,28 +27,31 @@ function ff(args) {
}); });
} }
const psnr = await ff(["-i", a, "-i", b, "-lavfi", "psnr", "-f", "null", "-"]); const NUM = "([-+0-9.eE]+)";
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 = (text, re) => { const num = (text, re) => {
const m = text.match(re); const m = text.match(re);
return m ? Number(m[1]) : NaN; 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 = { const frames = [...stats.matchAll(/frame:(\d+)/g)].length;
psnr_avg: /average:inf/.test(psnr) ? Infinity : num(psnr, /average:([\d.]+)/), const yavg = num(stats, new RegExp(`YAVG=${NUM}`));
ssim_all: num(ssim, /All:([\d.]+)/), const ymax = num(stats, new RegExp(`YMAX=${NUM}`));
mean_abs_diff: yavgs.length ? yavgs[yavgs.length - 1] : NaN, 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) { if (outPng) {
await ff(["-y", "-i", a, "-i", b, "-lavfi", "blend=all_mode=difference", "-frames:v", "1", outPng]); await ff(["-y", "-i", a, "-i", b, "-lavfi", "blend=all_mode=difference", "-frames:v", "1", outPng]);
+8 -1
View File
@@ -88,6 +88,8 @@ const DRIVER = (q) => `<script>
// 保证"同一份代码的任何两次截图"是同一相位,对拍才有意义。 // 保证"同一份代码的任何两次截图"是同一相位,对拍才有意义。
if (freeze) { if (freeze) {
try { try {
this.paused = true;
this.speed = 0;
var cur = this.animationState && this.animationState.getCurrent(0); var cur = this.animationState && this.animationState.getCurrent(0);
if (cur) cur.trackTime = 0; if (cur) cur.trackTime = 0;
if (this.skeleton) this.skeleton.updateWorldTransform(2); if (this.skeleton) this.skeleton.updateWorldTransform(2);
@@ -100,7 +102,12 @@ const DRIVER = (q) => `<script>
function freezePlayer(p) { function freezePlayer(p) {
try { try {
p.pause(); // 冻结姿势。关键在 paused=true:drawFrame 里 playDelta = paused ? 0 : delta * speed,
// 于是 animationState.update(0) 不会推进相位。只把 trackTime 置 0 是不够的——
// 那是在播放器 update(playDelta) 之前设的,渲染出来的仍是 t=playDelta 的姿势,
// 而 playDelta 随帧时长浮动,会留下"边缘亚像素抖动"式的伪差异。
p.paused = true;
p.speed = 0;
var cur = p.animationState && p.animationState.getCurrent(0); var cur = p.animationState && p.animationState.getCurrent(0);
if (cur) cur.trackTime = 0; if (cur) cur.trackTime = 0;
if (p.skeleton) p.skeleton.updateWorldTransform(2); if (p.skeleton) p.skeleton.updateWorldTransform(2);