feat: 比例适配、bgm 音源选择与 fps 限流,打包 v3
取景(issue 04) - 新增 viewport-fitter.js:让立绘层缩放跟随背景图的 cover 缩放——背景放大多少倍、 特效就放大多少倍。原先背景走 cover、立绘层走 contain,比例一变两者就分叉。 16:9 下与作者设定逐像素一致(kv37 为 0 差异)。实测证伪了"contain 会裁掉作者视口" 这个最初的判断:contain 在任何比例下都完整含住作者视口。 - 应用方式为原地改写 config.viewport 字段后调 setViewport(),不重建播放器; 维护挂在 config.frame 上按画布比例增量进行(config.success 早于 setViewport,拿不到视口)。 WE 属性(issue 05) - project.json 新增 bgm combo(所有壁纸音源的并集 + 「随预设」);所选项不属于当前壁纸时 音频回落该壁纸默认,选择本身保留。 - fps 限流包裹实例 drawFrame 并接管排程。修掉两个实测缺陷:朴素丢帧会把 rAF 抖动放大成 整拍丢失(目标 30fps 实测只有 22.5fps);fps:0 被真值判断吞掉导致无法取消限流。 打包(issue 06) - project.json version 升至 3 并更新描述;preset 的 combo 取值保持 xilian/kv37 不变。 - preview.gif 821 KB → 347 KB(每 2 帧取 1 帧 + 128 色不抖动,逐帧与源帧像素完全相同)。 - index.html 加内联 favicon,消除 CEF 自动请求 /favicon.ico 造成的 404 报错。 其它 - dist/scripts/wallpaper-engine.js 移到 tools/:它没有任何入口引用,且环境判据依赖真实 WE 并不存在的 wallpaperRegisterListener,一旦按它自己的说明挂进 index.html 会在生产环境自我激活。 - 修正死文件检查:原检查会拿文件名匹配到文件自身的内容,使这类文件漏网。 - 新增离线算式验证与 resize / 属性 / 全量验收三套浏览器测试脚本。
This commit is contained in:
1 parent
4662856963
commit
eb56efbd9e
22 files changed
+2766
-1012
No files matched your search
Vendored
+40
-4
@@ -22,10 +22,35 @@ const state = {
|
||||
presetId: "",
|
||||
audioSource: "", // 空字符串 = 用当前预设的默认音源
|
||||
audioVolume: DEFAULT_VOLUME,
|
||||
bgm: "", // 空字符串 = 随预设;否则是某个 preset.js 里 audioChoices 的 id
|
||||
};
|
||||
|
||||
let appliedPresetId = "";
|
||||
|
||||
// 量出背景图的真实宽高比:立绘层要靠它才能和背景同步缩放。
|
||||
// 直接读图片,避免在预设里再手写一个容易写错的数字。
|
||||
function measureImageAspect(url) {
|
||||
return new Promise((resolve) => {
|
||||
if (!url) {
|
||||
resolve(undefined);
|
||||
return;
|
||||
}
|
||||
const probe = new Image();
|
||||
probe.onload = () => resolve(probe.naturalWidth / probe.naturalHeight);
|
||||
probe.onerror = () => resolve(undefined);
|
||||
probe.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
// bgm combo 选中的音源只在本壁纸声明过时才生效。否则回落本壁纸默认音源——
|
||||
// 选择本身记着不动,切回那份壁纸时用户的选择还在。
|
||||
function bgmSource(preset) {
|
||||
if (!state.bgm) return "";
|
||||
const choices = preset.audioChoices || [];
|
||||
const hit = choices.find((choice) => choice.id === state.bgm);
|
||||
return hit ? hit.source : "";
|
||||
}
|
||||
|
||||
function render() {
|
||||
const presetId = state.presetId || DEFAULT_PRESET_ID;
|
||||
const preset = presets.get(presetId);
|
||||
@@ -37,10 +62,14 @@ function render() {
|
||||
appliedPresetId = presetId;
|
||||
backgroundController.setImage(preset.backgroundImage);
|
||||
spineController.setConfig(preset.spineConfig); // 播放器还不存在时只是记下来
|
||||
// 图片解码是异步的,回来时可能已经切到别的预设了,所以要核对一次
|
||||
measureImageAspect(preset.backgroundImage).then((aspect) => {
|
||||
if (appliedPresetId === presetId) spineController.setImageAspect(aspect);
|
||||
});
|
||||
}
|
||||
|
||||
// 用户自定义音源优先;用户没给就用当前壁纸的默认音源
|
||||
audioController.setSource(state.audioSource || preset.audioOptions.source);
|
||||
// 音源优先级:用户自填 URL > bgm 里选的(若本壁纸有)> 本壁纸默认音源
|
||||
audioController.setSource(state.audioSource || bgmSource(preset) || preset.audioOptions.source);
|
||||
audioController.setVolume(state.audioVolume);
|
||||
if (state.ready) audioController.play();
|
||||
}
|
||||
@@ -68,6 +97,11 @@ window.wallpaperPropertyListener = {
|
||||
state.audioSource = String(properties.audio_file.value || "").trim();
|
||||
}
|
||||
|
||||
if (properties.bgm) {
|
||||
const bgm = String(properties.bgm.value || "").trim();
|
||||
state.bgm = bgm === "auto" ? "" : bgm;
|
||||
}
|
||||
|
||||
if (properties.audio_volume) {
|
||||
const volume = Number(properties.audio_volume.value);
|
||||
if (Number.isFinite(volume)) {
|
||||
@@ -79,8 +113,10 @@ window.wallpaperPropertyListener = {
|
||||
},
|
||||
|
||||
applyGeneralProperties: function (properties) {
|
||||
if (properties.fps) {
|
||||
// fps 限制在第 05 步接入:WE 不替壁纸限流,需要自己按 properties.fps 节流绘制
|
||||
// WE 不会替壁纸限流,fps 得自己落实。这个属性与预设无关,不进 state。
|
||||
// 判 undefined 而不是真值:0 表示不限流,用真值判断会把"取消限流"整个吞掉。
|
||||
if (properties.fps !== undefined) {
|
||||
spineController.setFps(properties.fps);
|
||||
}
|
||||
},
|
||||
};
|
||||
Vendored
+127
-3
@@ -1,9 +1,14 @@
|
||||
// 负责把「当前预设的 spineConfig」应用到 Spine 播放器上。
|
||||
import { REFERENCE_ASPECT, contain, frameForAspect, resolveViewport, toViewportConfig } from "./viewport-fitter.js";
|
||||
|
||||
// 负责把「当前预设的 spineConfig」应用到 Spine 播放器上,并维持立绘层与背景图的取景同步。
|
||||
//
|
||||
// 核心约定:写入与应用分开。WE 可能在 window.load 之前就下发用户属性,那时播放器还不存在;
|
||||
// 核心约定一:写入与应用分开。WE 可能在 window.load 之前就下发用户属性,那时播放器还不存在;
|
||||
// 旧实现把配置赋值也塞在 `if (this.player)` 里,于是那次配置被静默丢弃,
|
||||
// 结果是"背景与音乐已切到新壁纸、立绘还是旧壁纸"的混合状态(已实测复现)。
|
||||
// 现在 setConfig() 无论如何都记下配置,create() 用记下的配置创建。
|
||||
//
|
||||
// 核心约定二:窗口尺寸变化绝不重建播放器。重建意味着重新下载 3.4 MB 骨架与全部贴图;
|
||||
// 取景只是 viewport 的算术,改写 config.viewport 后调 setViewport() 就地生效。
|
||||
export default class SpineController {
|
||||
constructor(containerId, baseConfig = {}) {
|
||||
this.containerId = containerId;
|
||||
@@ -18,21 +23,87 @@ export default class SpineController {
|
||||
this.presetConfig = {};
|
||||
this.container = document.getElementById(containerId);
|
||||
this.player = undefined;
|
||||
|
||||
this.framingEnabled = true;
|
||||
this.imageAspect = undefined; // 背景图宽高比,决定立绘层与背景的缩放关系
|
||||
this.framing = undefined; // 作者视口(换算到参考比例后的可见矩形)+ 动画名
|
||||
this.appliedAspect = undefined; // 上一次已应用的画布比例
|
||||
|
||||
this.fps = 0; // 0 = 不限流。WE 不会替壁纸限流,得自己门控绘制
|
||||
this.rawDrawFrame = undefined; // 被包裹前的 drawFrame,用于换档时摘掉旧包装
|
||||
}
|
||||
|
||||
// 只写状态:与播放器是否存在无关,随时可调用
|
||||
setConfig(presetConfig = {}) {
|
||||
this.presetConfig = presetConfig;
|
||||
// 预设可声明 framing:"author" 保留作者原始的「含住视口」行为,不跟随背景缩放
|
||||
this.framingEnabled = presetConfig.framing !== "author";
|
||||
this.framing = undefined;
|
||||
this.appliedAspect = undefined;
|
||||
if (this.player) this.rebuild();
|
||||
}
|
||||
|
||||
// 背景图换了,取景常数也要跟着换(比例一变就重新推导)
|
||||
setImageAspect(aspect) {
|
||||
if (!(aspect > 0) || aspect === this.imageAspect) return;
|
||||
this.imageAspect = aspect;
|
||||
this.appliedAspect = undefined;
|
||||
}
|
||||
|
||||
create() {
|
||||
if (this.player) return;
|
||||
// 每次都用 baseConfig + 当前 presetConfig 现算,避免上一份预设的键残留
|
||||
// 每次都用 baseConfig + 当前 presetConfig 现算,避免上一份预设的键残留。
|
||||
// frame 放在最后:它是内部钩子,不允许被预设数据覆盖。
|
||||
this.rawDrawFrame = undefined;
|
||||
this.player = new spine.SpinePlayer(this.containerId, {
|
||||
...this.baseConfig,
|
||||
...this.presetConfig,
|
||||
frame: (player) => this.maintainFraming(player),
|
||||
});
|
||||
this.applyFpsLimit();
|
||||
}
|
||||
|
||||
// WE 经 applyGeneralProperties 下发 fps,但它不会替壁纸限流,必须自行实现。
|
||||
setFps(fps) {
|
||||
const value = Number(fps);
|
||||
this.fps = Number.isFinite(value) && value > 0 ? value : 0;
|
||||
this.applyFpsLimit();
|
||||
}
|
||||
|
||||
// 门控绘制:把实例上的 drawFrame 换成一层包装。
|
||||
// 播放器的循环写作 requestAnimationFrame(() => this.drawFrame())——箭头闭包在调用时才读
|
||||
// this.drawFrame,所以在实例上换掉它就能接管排程。
|
||||
// 关键点:原实现只在真正绘制时才排下一帧,而被限流丢掉的帧同样要排,否则循环会停摆。
|
||||
applyFpsLimit() {
|
||||
const player = this.player;
|
||||
if (!player) return;
|
||||
|
||||
// 先摘掉上一轮包装,避免换档时叠成多层
|
||||
if (this.rawDrawFrame) {
|
||||
player.drawFrame = this.rawDrawFrame;
|
||||
this.rawDrawFrame = undefined;
|
||||
}
|
||||
if (!this.fps) return;
|
||||
|
||||
const raw = (this.rawDrawFrame = player.drawFrame);
|
||||
const interval = 1000 / this.fps;
|
||||
// rAF 的节拍有抖动,而"距上一帧不足 interval 就丢"会把抖动放大成整拍丢失:
|
||||
// 目标是 30fps 时,落在 33.2ms 的那一帧会被判为不足 33.33ms 而丢掉,实测只剩 22.5fps。
|
||||
// 所以留一点容差,并让到期时间按固定步长推进,避免抖动被累积成额外丢帧。
|
||||
const tolerance = 4;
|
||||
let due = -Infinity;
|
||||
|
||||
player.drawFrame = (requestNextFrame = true) => {
|
||||
if (player.disposed || player.error) return;
|
||||
if (requestNextFrame && !player.stopRequestAnimationFrame) {
|
||||
requestAnimationFrame(() => player.drawFrame());
|
||||
}
|
||||
const now = performance.now();
|
||||
if (now + tolerance < due) return; // 本帧丢弃:不推进动画,也不绘制
|
||||
due = due < 0 || now - due > interval ? now + interval : due + interval;
|
||||
// 排程已由本层负责,让原实现不要再排一次
|
||||
raw.call(player, false);
|
||||
};
|
||||
}
|
||||
|
||||
// 换骨架没有捷径,只能销毁重建
|
||||
@@ -46,5 +117,58 @@ export default class SpineController {
|
||||
this.player.dispose();
|
||||
this.container.innerHTML = "";
|
||||
this.player = undefined;
|
||||
this.rawDrawFrame = undefined;
|
||||
this.framing = undefined;
|
||||
this.appliedAspect = undefined;
|
||||
}
|
||||
|
||||
// 播放器每帧在算相机之前调用它(drawFrame 内,renderer.resize 之后)。
|
||||
// 以画布比例作键:比例没变就是一次比较后返回,不会给每帧加负担。
|
||||
maintainFraming(player) {
|
||||
if (!this.framingEnabled) return;
|
||||
|
||||
const canvas = player.canvas;
|
||||
if (!canvas || !canvas.clientWidth || !canvas.clientHeight) return;
|
||||
const aspect = canvas.clientWidth / canvas.clientHeight;
|
||||
if (aspect === this.appliedAspect) return;
|
||||
|
||||
// 骨架加载完成前 currentViewport 还不存在,下一帧再试
|
||||
if (!this.framing && !this.captureFraming(player)) return;
|
||||
this.applyFraming(player, aspect);
|
||||
}
|
||||
|
||||
// 记下作者视口在参考比例下的取景;之后所有比例都从它推导
|
||||
captureFraming(player) {
|
||||
const viewport = player.currentViewport;
|
||||
if (!viewport) return undefined;
|
||||
|
||||
let animation = this.presetConfig.animation;
|
||||
if (!animation) {
|
||||
const entry = player.animationState && player.animationState.getCurrent(0);
|
||||
animation = entry && entry.animation && entry.animation.name;
|
||||
}
|
||||
if (!animation) return undefined;
|
||||
|
||||
this.framing = {
|
||||
animation,
|
||||
reference: contain(resolveViewport(viewport), REFERENCE_ASPECT),
|
||||
};
|
||||
return this.framing;
|
||||
}
|
||||
|
||||
applyFraming(player, aspect) {
|
||||
const { animation, reference } = this.framing;
|
||||
const target = this.imageAspect
|
||||
? frameForAspect(reference, aspect, this.imageAspect)
|
||||
: contain(reference, aspect);
|
||||
|
||||
// 就地改写字段,绝不整体替换 config.viewport:
|
||||
// setViewport() 会无条件读 config.viewport.animations[name],换掉整个对象会崩。
|
||||
Object.assign(player.config.viewport, toViewportConfig(target));
|
||||
|
||||
player.setViewport(animation);
|
||||
// 取景要随窗口实时变化,0.25 秒的过渡插值只会拖出残影:直接跳到位
|
||||
player.viewportTransitionStart = performance.now() - 1e6;
|
||||
this.appliedAspect = aspect;
|
||||
}
|
||||
}
|
||||
Vendored
+64
@@ -0,0 +1,64 @@
|
||||
// 立绘层的取景计算。纯函数:只做世界坐标矩形的算术,不碰 DOM,也不碰播放器实例。
|
||||
//
|
||||
// 矩形一律是 { x, y, width, height },世界单位,y 轴向上。
|
||||
//
|
||||
// 为什么需要这一层:
|
||||
// 背景图走 CSS `background-size: cover`(铺满、按需裁切),
|
||||
// 立绘层却由播放器按「含住作者视口」映射(contain)。两者只在作者调参的那个比例下一致,
|
||||
// 比例一变,缩放倍率就分叉——竖屏下背景放大 1.78 倍,立绘层却纹丝不动,特效于是和背景插画脱节。
|
||||
// 这里让立绘层的缩放跟着背景 cover 的缩放走,两者始终同倍率、同中心。
|
||||
|
||||
// 作者调参时的基准比例。在这个比例下取景与作者设定逐像素一致。
|
||||
export const REFERENCE_ASPECT = 16 / 9;
|
||||
|
||||
// 播放器把 currentViewport(基矩形 + 四边内边距)解成最终可见矩形。
|
||||
// 对应 spine-player.js 里 `viewport.x = currentViewport.x - padLeft` 那四行。
|
||||
export function resolveViewport(viewport) {
|
||||
return {
|
||||
x: viewport.x - viewport.padLeft,
|
||||
y: viewport.y - viewport.padBottom,
|
||||
width: viewport.width + viewport.padLeft + viewport.padRight,
|
||||
height: viewport.height + viewport.padBottom + viewport.padTop,
|
||||
};
|
||||
}
|
||||
|
||||
// 把矩形按 aspect 撑到刚好含住它,中心不变。这正是播放器在参考比例下的行为。
|
||||
export function contain(rect, aspect) {
|
||||
const width = Math.max(rect.width, rect.height * aspect);
|
||||
const height = width / aspect;
|
||||
const cx = rect.x + rect.width / 2;
|
||||
const cy = rect.y + rect.height / 2;
|
||||
return { x: cx - width / 2, y: cy - height / 2, width, height };
|
||||
}
|
||||
|
||||
// 背景在某个画布比例下的 cover 缩放,以「受高度约束时的倍率」为 1 归一化。
|
||||
// 背景比画布更宽时按高度铺满(倍率不变),画布更宽时按宽度铺满(倍率随 aspect 增长)。
|
||||
export function coverScale(aspect, imageAspect) {
|
||||
return Math.max(aspect / imageAspect, 1);
|
||||
}
|
||||
|
||||
// 目标可见矩形:参考比例下正好等于作者视口,其余比例按背景 cover 缩放的反比收放。
|
||||
// 效果等价于「立绘层相对背景插画永远 cover」。
|
||||
export function frameForAspect(reference, aspect, imageAspect, referenceAspect = REFERENCE_ASPECT) {
|
||||
const height =
|
||||
(reference.height * coverScale(referenceAspect, imageAspect)) / coverScale(aspect, imageAspect);
|
||||
const width = height * aspect;
|
||||
const cx = reference.x + reference.width / 2;
|
||||
const cy = reference.y + reference.height / 2;
|
||||
return { x: cx - width / 2, y: cy - height / 2, width, height };
|
||||
}
|
||||
|
||||
// 把目标可见矩形写成播放器 config.viewport 需要的字段:内边距归零,
|
||||
// 这样 resolveViewport() 得到的就是它本身。
|
||||
export function toViewportConfig(rect) {
|
||||
return {
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
padLeft: 0,
|
||||
padRight: 0,
|
||||
padTop: 0,
|
||||
padBottom: 0,
|
||||
};
|
||||
}
|
||||
Vendored
-974
@@ -1,974 +0,0 @@
|
||||
// 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,
|
||||
};
|
||||
Reference in new issue
Block a user