diff --git a/.gitignore b/.gitignore index a0f9299..178544c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,10 @@ tools/shots/ tools/.cache/ +# 对拍中间产物:截图、抽取帧、试压的 GIF 都是可重生成的二进制,不入库 +.scratch/*.png +.scratch/*.gif + # 无损音频母带:只在本地留存,绝不进 dist、绝不入库(体积大且与发布无关) masters/ diff --git a/.scratch/fit-measure.ps1 b/.scratch/fit-measure.ps1 new file mode 100644 index 0000000..f329039 --- /dev/null +++ b/.scratch/fit-measure.ps1 @@ -0,0 +1,26 @@ +$ErrorActionPreference = "Stop" +Set-Location D:\Workspace\Temp\SpineWallpaper + +$sizes = @( + @(3440, 1440, "219"), + @(1920, 1080, "169"), + @(1080, 1920, "916") +) +$props = @{ + "xilian" = "" + "kv37" = "&__props=%7B%22preset%22%3A%22kv37%22%7D" +} + +New-Item -ItemType Directory -Force -Path tools\shots\fit | Out-Null + +foreach ($p in @("xilian", "kv37")) { + foreach ($s in $sizes) { + $w = $s[0]; $h = $s[1]; $tag = $s[2] + $url = "http://127.0.0.1:8190/?__freeze=1" + $props[$p] + Write-Host "=== $p $tag ($w x $h) ===" + node tools/shot.mjs "tools/shots/fit/${p}_${tag}.png" $url $w $h 2>&1 | Select-Object -First 1 + node tools/shot.mjs "tools/shots/fit/${p}_${tag}_ref.png" "$url&__hide=canvas" $w $h 2>&1 | Select-Object -First 1 + node tools/subject.mjs "tools/shots/fit/${p}_${tag}.png" "tools/shots/fit/${p}_${tag}_ref.png" + Write-Host "" + } +} diff --git a/.scratch/test-acceptance.mjs b/.scratch/test-acceptance.mjs new file mode 100644 index 0000000..ef4104a --- /dev/null +++ b/.scratch/test-acceptance.mjs @@ -0,0 +1,135 @@ +// Issue 06 全量验收。 +// ① dist 体积与死文件;② project.json 与代码一致;③ 三种比例 0 控制台报错; +// ④ 预设往返不丢用户级音源/音量设置。 +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { basename, extname, join } from "node:path"; +import { withPage, sleep } from "../tools/cdp.mjs"; + +let failures = 0; +const check = (ok, msg, detail) => { + if (!ok) failures++; + console.log(` ${ok ? "✓" : "✗"} ${msg}${detail !== undefined ? ` → ${detail}` : ""}`); +}; + +// ── ① 体积与死文件 ── +console.log("=== ① dist 体积与死文件 ==="); +const walk = (dir) => + readdirSync(dir, { withFileTypes: true }).flatMap((e) => { + const p = join(dir, e.name); + return e.isDirectory() ? walk(p) : [p]; + }); +const files = walk("dist"); +const total = files.reduce((s, f) => s + statSync(f).size, 0); +check(total <= 35 * 1024 * 1024, "总体积 ≤ 35 MB", `${(total / 1048576).toFixed(2)} MB / ${files.length} 个文件`); + +const TEXT = new Set([".html", ".js", ".json", ".txt", ".css", ".atlas"]); +const texts = files.filter((f) => TEXT.has(extname(f))); +const contents = new Map(texts.map((f) => [f, readFileSync(f, "utf8")])); +// 这四个是 WE 的元数据/入口/说明,本来就不被页面引用 +const ALLOWED = new Set(["project.json", "README.txt", "index.html", "preview.gif"]); +// 关键:比对时要把文件**自身**的内容排除掉。否则一个文件只要在自己开头写上自己的名字 +// (比如注释里的 "// wallpaper-engine.js")就会被判为"被引用",死文件因此漏网。 +const dead = files.filter((f) => { + const n = basename(f); + if (ALLOWED.has(n)) return false; + const others = texts + .filter((t) => t !== f) + .map((t) => contents.get(t)) + .join("\n"); + return !others.includes(n); +}); +check(dead.length === 0, "0 个死文件", dead.length ? dead.join(", ") : "无孤立资源"); + +// ── ② project.json ── +console.log("\n=== ② project.json ==="); +const project = JSON.parse(readFileSync("dist/project.json", "utf8")); +check(project.version === 3, "version = 3", String(project.version)); +check(project.preview === "preview.gif", "preview 指向 preview.gif"); +check(!!project.general.properties.bgm, "新增 bgm combo"); +check(project.general.properties.preset.value === "xilian", "preset 默认值未变"); +const presetValues = project.general.properties.preset.options.map((o) => o.value).sort(); +check( + JSON.stringify(presetValues) === JSON.stringify(["kv37", "xilian"]), + "preset 的 combo 取值未变(用户已保存的设置依赖它)", + presetValues.join(", "), +); +const bgmValues = project.general.properties.bgm.options.map((o) => o.value); +check(bgmValues.includes("auto"), "bgm 含「随预设」档", bgmValues.join(", ")); +const gifSize = statSync("dist/preview.gif").size; +check(gifSize < 500 * 1024, "preview.gif < 500 KB", `${(gifSize / 1024).toFixed(0)} KB`); + +// ── ③ 三种比例 0 报错 ── +console.log("\n=== ③ 21:9 / 16:9 / 9:16 控制台与异常 ==="); +for (const [w, h, tag] of [ + [3440, 1440, "21:9"], + [1920, 1080, "16:9"], + [1080, 1920, "9:16"], +]) { + const r = await withPage({ width: w, height: h }, async ({ send, evaluate, consoleLines, exceptions }) => { + await send("Page.navigate", { url: "http://127.0.0.1:8190/?__freeze=1" }); + const deadline = Date.now() + 30000; + let ready = false; + while (Date.now() < deadline) { + if (await evaluate('!!(window.__test && (window.__test.successAt || window.__test.error))')) { + ready = true; + break; + } + await sleep(150); + } + await sleep(500); + const state = await evaluate(`(function(){ + var p = window.__player, v = p.currentViewport; + return { w: p.canvas.clientWidth, h: p.canvas.clientHeight, + vw: +v.width.toFixed(1), vh: +v.height.toFixed(1), + err: window.__test.error || null }; + })()`); + return { ready, state, consoleLines, exceptions }; + }); + const noisy = r.consoleLines.filter((l) => /error|uncaught|fail/i.test(l)); + const aspectOk = r.state && Math.abs(r.state.vw / r.state.vh - r.state.w / r.state.h) < 0.002; + check(r.ready && !r.state?.err, `${tag} 播放器就绪`, r.state?.err ?? "无错"); + check(aspectOk, `${tag} 可见矩形比例与画布一致`, r.state ? `${r.state.vw}×${r.state.vh}` : "—"); + check(noisy.length === 0 && r.exceptions.length === 0, `${tag} 0 控制台报错 / 0 异常`, noisy.concat(r.exceptions).slice(0, 3).join(" | ") || "干净"); +} + +// ── ④ 预设往返不丢音频设置 ── +console.log("\n=== ④ 预设往返 10 次,音频设置不得丢失 ==="); +const rt = await withPage({ width: 1280, height: 720 }, async ({ send, evaluate, exceptions }) => { + await send("Page.navigate", { url: "http://127.0.0.1:8190/?__freeze=1" }); + const deadline = Date.now() + 30000; + while (Date.now() < deadline) { + if (await evaluate('!!(window.__test && (window.__test.successAt || window.__test.error))')) break; + await sleep(150); + } + await sleep(500); + + await evaluate(`window.wallpaperPropertyListener.applyUserProperties({ + audio_file: { value: "https://example.com/custom-track.mp3" }, + audio_volume: { value: 0.2 } + })`); + await sleep(300); + + const rows = []; + for (let i = 0; i < 10; i++) { + const id = i % 2 === 0 ? "kv37" : "xilian"; + await evaluate(`window.wallpaperPropertyListener.applyUserProperties({ preset: { value: "${id}" } })`); + await sleep(320); + rows.push( + await evaluate(`(function(){ + var a = document.getElementById("background-music"); + return { id: "${id}", src: a.src.split("/").pop(), vol: a.volume, + draws: window.__test.drawCalls }; + })()`), + ); + } + return { rows, exceptions }; +}); +const srcOk = rt.rows.every((r) => r.src === "custom-track.mp3"); +const volOk = rt.rows.every((r) => Math.abs(r.vol - 0.2) < 0.001); +check(srcOk, "10 次切换后自定义音源仍生效", [...new Set(rt.rows.map((r) => r.src))].join(", ")); +check(volOk, "10 次切换后音量仍为 0.2", [...new Set(rt.rows.map((r) => r.vol))].join(", ")); +check(rt.rows[rt.rows.length - 1].draws > rt.rows[0].draws, "切换后仍在持续绘制", `${rt.rows[0].draws} → ${rt.rows[rt.rows.length - 1].draws}`); +check(rt.exceptions.length === 0, "往返过程 0 未捕获异常", rt.exceptions.join(" | ") || "无"); + +console.log(`\n结论:${failures === 0 ? "全部通过 ✓" : `${failures} 项失败 ✗`}`); +process.exit(failures === 0 ? 0 : 1); diff --git a/.scratch/test-props.mjs b/.scratch/test-props.mjs new file mode 100644 index 0000000..cfea666 --- /dev/null +++ b/.scratch/test-props.mjs @@ -0,0 +1,137 @@ +// Issue 05 验收:bgm 音源 combo 与 fps 限流。 +// +// A 段验音源优先级:用户自填 URL > bgm 选择(仅当本壁纸声明过)> 本壁纸默认音源; +// 且 bgm 选择跨壁纸时"音频回落、选择保留"。 +// B 段验 fps:WE 不替壁纸限流,得自己门控。关键是限流只该丢帧, +// 不该改变动画相位推进速度——用同一段真实时间里 trackTime 前进了多少来判定。 +import { withPage, sleep } from "../tools/cdp.mjs"; + +const BASE = "http://127.0.0.1:8190/"; +const name = (src) => (src ? decodeURIComponent(String(src).split("/").pop()) : "(空)"); + +let failures = 0; +const check = (ok, msg, detail) => { + if (!ok) failures++; + console.log(` ${ok ? "✓" : "✗"} ${msg}${detail !== undefined ? ` → ${detail}` : ""}`); +}; + +// ───────────────────────── A 段:音源 ───────────────────────── +console.log("=== A 段:bgm 音源选择与回落 ==="); +await withPage({ width: 1280, height: 720 }, async ({ send, evaluate, exceptions }) => { + await send("Page.navigate", { url: BASE + "?__freeze=1" }); + const deadline = Date.now() + 30000; + while (Date.now() < deadline) { + if (await evaluate('!!(window.__test && (window.__test.successAt || window.__test.error))')) break; + await sleep(150); + } + await sleep(600); + + const src = () => evaluate('document.getElementById("background-music").src'); + const preset = (v) => evaluate(`window.wallpaperPropertyListener.applyUserProperties({ preset: { value: ${JSON.stringify(v)} } })`); + const bgm = (v) => evaluate(`window.wallpaperPropertyListener.applyUserProperties({ bgm: { value: ${JSON.stringify(v)} } })`); + const file = (v) => evaluate(`window.wallpaperPropertyListener.applyUserProperties({ audio_file: { value: ${JSON.stringify(v)} } })`); + + const seen = []; + const step = async (label, action, expected) => { + await action(); + await sleep(350); + const got = name(await src()); + seen.push(`${label}=${got}`); + check(got === expected, label, `期望 ${expected},实得 ${got}`); + }; + + // 载入即 xilian,默认音源应是「再度和你」 + check(name(await src()) === "再度和你.ogg", "初始默认音源 = 昔涟立绘默认", name(await src())); + + await step("bgm 选「昔涟」", () => bgm("xilian"), "昔涟.ogg"); + await step("切到 kv37(选择不属于它)→ 回落", () => preset("kv37"), "pv37.mp3"); + await step("bgm 选「版本 PV」(属于 kv37)", () => bgm("pv37"), "pv37.mp3"); + await step("切回 xilian(pv37 不属于它)→ 回落", () => preset("xilian"), "再度和你.ogg"); + await step("bgm 选「「再度和你」」", () => bgm("zaiduheni"), "再度和你.ogg"); + await step("bgm 回到「随预设」", () => bgm("auto"), "再度和你.ogg"); + + // 用户自填 URL 优先级最高 + await step("bgm 选「昔涟」后填自定义 URL", async () => { + await bgm("xilian"); + await file("https://example.com/custom-track.mp3"); + }, "custom-track.mp3"); + await step("切壁纸不得清掉自定义 URL", () => preset("kv37"), "custom-track.mp3"); + await step("清空自定义 URL → 回到预设默认", () => file(""), "pv37.mp3"); + + console.log(` 音源轨迹: ${seen.join(" | ")}`); + console.log(` 未捕获异常: ${exceptions.length ? exceptions.join(" | ") : "无 ✓"}`); + if (exceptions.length) failures++; +}); + +// ───────────────────────── B 段:fps ───────────────────────── +console.log("\n=== B 段:fps 限流 ==="); +await withPage({ width: 1280, height: 720 }, async ({ send, evaluate, exceptions }) => { + // 空 __props 只用来触发测试驱动注入(不带它就不会注入),既不冻结也不改属性 + await send("Page.navigate", { url: BASE + "?__props=%7B%7D" }); + const deadline = Date.now() + 30000; + while (Date.now() < deadline) { + if (await evaluate('!!(window.__test && (window.__test.successAt || window.__test.error))')) break; + await sleep(150); + } + await sleep(1200); // 让动画先跑起来,避开启动瞬态 + + const probe = `(function(){ + var p = window.__player; + var e = p.animationState.getCurrent(0); + return { trackTime: e.trackTime, duration: e.animation.duration, + draws: window.__test.drawCalls, t: performance.now() }; + })()`; + + const sample = async (seconds) => { + const a = await evaluate(probe); + await sleep(seconds * 1000); + const b = await evaluate(probe); + const wall = (b.t - a.t) / 1000; + let phase = b.trackTime - a.trackTime; + if (phase < 0) phase += b.duration; // 动画正好绕回 + return { wall, draws: b.draws - a.draws, fps: (b.draws - a.draws) / wall, phaseRate: phase / wall }; + }; + + const setFps = async (v) => { + await evaluate(`window.wallpaperPropertyListener.applyGeneralProperties({ fps: ${v} })`); + await sleep(400); + }; + + const results = []; + const run = async (label, target) => { + const r = await sample(2.0); + const row = { label, target, ...r }; + results.push(row); + console.log( + ` ${label.padEnd(16)} 实测 ${r.fps.toFixed(1)} fps` + + (target ? `(目标 ${target})` : "(不限流)") + + ` 动画相位推进 ${r.phaseRate.toFixed(3)} 倍速`, + ); + return row; + }; + + const base = await run("不限流", 0); + await setFps(30); + const f30 = await run("限 30 fps", 30); + await setFps(20); + const f20 = await run("限 20 fps", 20); + await setFps(0); + const back = await run("恢复不限流", 0); + + check(Math.abs(f30.fps - 30) / 30 < 0.25, "限 30 fps 生效", `实测 ${f30.fps.toFixed(1)}`); + check(Math.abs(f20.fps - 20) / 20 < 0.25, "限 20 fps 生效", `实测 ${f20.fps.toFixed(1)}`); + check(f30.fps < base.fps * 0.75, "限流确实减少了绘制次数", `${base.fps.toFixed(1)} → ${f30.fps.toFixed(1)}`); + check(back.fps > f30.fps * 1.5, "取消限流后帧率恢复", `${f30.fps.toFixed(1)} → ${back.fps.toFixed(1)}`); + for (const r of [f30, f20, back]) { + check( + Math.abs(r.phaseRate - base.phaseRate) / base.phaseRate < 0.1, + `动画相位推进速度未受 ${r.label} 影响`, + `${r.phaseRate.toFixed(3)} vs 基准 ${base.phaseRate.toFixed(3)}`, + ); + } + console.log(` 未捕获异常: ${exceptions.length ? exceptions.join(" | ") : "无 ✓"}`); + if (exceptions.length) failures++; +}); + +console.log(`\n结论:${failures === 0 ? "全部通过 ✓" : `${failures} 项失败 ✗`}`); +process.exit(failures === 0 ? 0 : 1); diff --git a/.scratch/test-resize.mjs b/.scratch/test-resize.mjs new file mode 100644 index 0000000..c883679 --- /dev/null +++ b/.scratch/test-resize.mjs @@ -0,0 +1,126 @@ +// 验收测试:改窗口尺寸不得重建播放器,且取景要跟着画布比例走。 +// +// 判据是"播放器实例身份":给加载后的实例打上标记,之后每次改视口都核对 +// window.__player 是否还是同一个对象、标记是否还在。重建会换掉实例, +// 标记随之消失——这比数日志可靠得多。 +import { withPage, sleep } from "../tools/cdp.mjs"; + +const URL = "http://127.0.0.1:8190/?__freeze=1"; +const sizes = [ + [1920, 1080], + [2560, 1440], + [3440, 1440], + [1080, 1920], + [1600, 900], + [2560, 1600], + [3840, 2160], + [1280, 720], + [1920, 1080], +]; +const stress = []; +for (let i = 0; i < 20; i++) stress.push(i % 2 ? [1080, 1920] : [1920, 1080]); + +const out = await withPage( + { width: 1920, height: 1080, extraArgs: ["--enable-precise-memory-info", "--js-flags=--expose-gc"] }, + async ({ send, evaluate, consoleLines, exceptions }) => { + await send("Page.navigate", { url: URL }); + const deadline = Date.now() + 30000; + while (Date.now() < deadline) { + if (await evaluate('!!(window.__test && (window.__test.successAt || window.__test.error))')) break; + await sleep(150); + } + await sleep(600); + + const stamp = await evaluate(`(function(){ + window.__first = window.__player; + if (window.__player) window.__player.__probe = 'A'; + return { hasPlayer: !!window.__player, error: window.__test ? window.__test.error : null }; + })()`); + + const rows = []; + const measure = async (w, h, tag) => { + await send("Emulation.setDeviceMetricsOverride", { + width: w, + height: h, + deviceScaleFactor: 1, + mobile: false, + }); + await sleep(420); + // 先强制回收再读堆,否则量到的是 V8 的延迟回收节奏而不是真实占用 + await evaluate('(typeof gc === "function") ? (gc(), true) : false'); + const r = await evaluate(`(function(){ + var p = window.__player; + if (!p) return null; + var v = p.currentViewport; + return { + same: p === window.__first, + probe: p.__probe, + css: [p.canvas.clientWidth, p.canvas.clientHeight], + resolved: [ +v.x.toFixed(2), +v.y.toFixed(2), +v.width.toFixed(2), +v.height.toFixed(2) ], + pad: [v.padLeft, v.padRight, v.padTop, v.padBottom], + childViews: window.__player.config.viewport.animations ? Object.keys(window.__player.config.viewport.animations).length : -1, + heap: performance.memory ? performance.memory.usedJSHeapSize : 0, + drawCalls: window.__test ? window.__test.drawCalls : 0 + }; + })()`); + rows.push({ tag, w, h, ...(r ?? { same: false, probe: null, css: null, resolved: null, pad: null, heap: 0, drawCalls: 0 }) }); + }; + + for (const [w, h] of sizes) await measure(w, h, `${w}x${h}`); + const heapBeforeStress = rows[rows.length - 1].heap; + for (const [w, h] of stress) await measure(w, h, `压力 ${w}x${h}`); + const heapAfterStress = rows[rows.length - 1].heap; + + return { stamp, rows, heapBeforeStress, heapAfterStress, consoleLines, exceptions }; + }, +); + +const { stamp, rows, heapBeforeStress, heapAfterStress, consoleLines, exceptions } = out; + +let failures = 0; +const fail = (msg) => { + failures++; + console.log(` ✗ ${msg}`); +}; + +console.log(`播放器就绪: ${stamp.hasPlayer ? "是" : "否"} 驱动报错: ${stamp.error ?? "无"}`); +console.log(""); +console.log("画布尺寸 实例未重建 内边距 可见矩形 比例核对"); +for (const r of rows) { + if (r.tag.startsWith("压力")) continue; + const a = r.w / r.h; + const ra = r.resolved ? r.resolved[2] / r.resolved[3] : NaN; + const ratioOk = Math.abs(ra - a) < 0.002; + const padOk = r.pad && r.pad.every((p) => p === 0); + const rect = r.resolved ? r.resolved.map((n) => String(n).padStart(9)).join(" ") : "—"; + console.log( + `${r.tag.padEnd(14)} ${String(r.same).padEnd(10)} ${(padOk ? "全 0 ✓" : JSON.stringify(r.pad)).padEnd(10)} ${rect} ${ratioOk ? "✓" : `✗ 期望 ${a.toFixed(4)} 实得 ${ra.toFixed(4)}`}`, + ); + if (!r.same) fail(`${r.tag} 重建了播放器(标记丢失: ${r.probe})`); + if (!padOk) fail(`${r.tag} 内边距没有归零`); + if (!ratioOk) fail(`${r.tag} 可见矩形比例与画布不符`); +} + +// 首尾同为 1920x1080:取景必须回到完全相同的值,证明重算可逆、无漂移 +const first = rows.find((r) => r.tag === "1920x1080"); +const last = [...rows].reverse().find((r) => r.tag === "1920x1080"); +const reversible = first.resolved.every((n, i) => Math.abs(n - last.resolved[i]) < 0.01); +if (!reversible) fail(`回到 16:9 后取景漂移:${first.resolved} → ${last.resolved}`); +console.log(`\n回到 16:9 取景可逆: ${reversible ? "✓" : "✗"} ${first.resolved} → ${last.resolved}`); + +const growth = heapAfterStress - heapBeforeStress; +console.log(`连续 20 次 resize 堆内存: ${(heapBeforeStress / 1048576).toFixed(1)} MB → ${(heapAfterStress / 1048576).toFixed(1)} MB(增长 ${(growth / 1048576).toFixed(2)} MB)`); +if (growth > 4 * 1048576) fail(`连续 resize 内存增长过大:${(growth / 1048576).toFixed(2)} MB`); + +const drawCalls = rows[rows.length - 1].drawCalls; +console.log(`累计绘制帧数: ${drawCalls}`); +if (!(drawCalls > 0)) fail("没有绘制任何帧"); + +const noisy = consoleLines.filter((l) => /error|warn|uncaught|fail/i.test(l)); +console.log(`控制台异常: ${noisy.length ? noisy.slice(0, 5).join(" | ") : "无 ✓"}`); +if (noisy.length) fail("控制台有报错/警告"); +console.log(`未捕获异常: ${exceptions.length ? exceptions.slice(0, 4).join(" | ") : "无 ✓"}`); +if (exceptions.length) fail("有未捕获异常"); + +console.log(`\n结论:${failures === 0 ? "全部通过 ✓" : `${failures} 项失败 ✗`}`); +process.exit(failures === 0 ? 0 : 1); diff --git a/.scratch/verify-fitter.mjs b/.scratch/verify-fitter.mjs new file mode 100644 index 0000000..165ee9c --- /dev/null +++ b/.scratch/verify-fitter.mjs @@ -0,0 +1,72 @@ +// 取景公式的离线验证:不依赖浏览器,直接对 viewport-fitter.js 求值。 +// 两条硬约束:① 16:9 下必须与作者设定完全一致;② 立绘层缩放比必须等于背景 cover 缩放比。 +import { readFileSync } from "node:fs"; + +const src = readFileSync(new URL("../dist/scripts/viewport-fitter.js", import.meta.url), "utf8").replace(/^export\s+/gm, ""); +const mod = new Function( + src + "\nreturn { contain, frameForAspect, coverScale, resolveViewport, toViewportConfig, REFERENCE_ASPECT };", +)(); + +const rect = (x, y, width, height) => ({ x, y, width, height }); +const fmt = (r) => + `x=${r.x.toFixed(2)} y=${r.y.toFixed(2)} w=${r.width.toFixed(2)} h=${r.height.toFixed(2)} 比例=${(r.width / r.height).toFixed(4)}`; + +const cases = [ + { + name: "xilian(昔涟立绘)", + R: rect(-2181.72, 1442.39, 4226.57, 3403.35), + imgW: 2048, + imgH: 1080, + }, + { + name: "kv37(「成为昨日的明天」)", + R: rect(-1419.8, -512.4, 2457.6, 1228.8), + imgW: 2560, + imgH: 1440, + }, +]; +const canvases = [ + [3440, 1440, "21:9"], + [1920, 1080, "16:9"], + [2560, 1600, "16:10"], + [1080, 1920, "9:16"], +]; + +// 背景走 CSS background-size:cover,它在画布上真实的像素缩放就是这两个候选里的较大者。 +const bgScale = (w, h, imgW, imgH) => Math.max(w / imgW, h / imgH); + +let failures = 0; +for (const c of cases) { + const imgAspect = c.imgW / c.imgH; + const V0 = mod.contain(c.R, mod.REFERENCE_ASPECT); + console.log(`\n=== ${c.name} 背景图 ${c.imgW}x${c.imgH}(比例 ${imgAspect.toFixed(4)}) ===`); + console.log(`作者视口 R : ${fmt(c.R)}`); + console.log(`参考取景 V0 : ${fmt(V0)}`); + + // 基准取 16:9 1080p:立绘与背景都以此为准比较"被放大了多少倍" + const refCanvas = [1920, 1080]; + const spinePx0 = refCanvas[1] / V0.height; + const bgPx0 = bgScale(refCanvas[0], refCanvas[1], c.imgW, c.imgH); + + for (const [w, h, tag] of canvases) { + const aspect = w / h; + const V = mod.frameForAspect(V0, aspect, imgAspect); + const spineRatio = h / V.height / spinePx0; + const bgRatio = bgScale(w, h, c.imgW, c.imgH) / bgPx0; + const synced = Math.abs(spineRatio - bgRatio) < 1e-6; + if (!synced) failures++; + console.log( + ` ${tag.padEnd(5)} ${String(w).padStart(4)}x${String(h).padEnd(4)} ${fmt(V)}` + + ` 立绘 x${spineRatio.toFixed(4)} 背景 x${bgRatio.toFixed(4)} ${synced ? "同步 ✓" : "不同步 ✗"}`, + ); + } + + // 参考比例下必须与作者设定逐像素一致,且必须与拟合前的真实渲染值吻合 + const Vref = mod.frameForAspect(V0, mod.REFERENCE_ASPECT, imgAspect); + const untouched = ["x", "y", "width", "height"].every((k) => Math.abs(Vref[k] - V0[k]) < 1e-9); + if (!untouched) failures++; + console.log(` 参考比例回归: ${untouched ? "与作者设定完全一致 ✓" : "被改动 ✗"}`); +} + +console.log(`\n结论:${failures === 0 ? "全部通过 ✓" : `${failures} 项失败 ✗`}`); +process.exit(failures === 0 ? 0 : 1); diff --git a/.scratch/wallpaper-optimization/issues/04-aspect-adaptation.md b/.scratch/wallpaper-optimization/issues/04-aspect-adaptation.md index 4d7e0ca..804a78b 100644 --- a/.scratch/wallpaper-optimization/issues/04-aspect-adaptation.md +++ b/.scratch/wallpaper-optimization/issues/04-aspect-adaptation.md @@ -1,20 +1,68 @@ # 04 比例适配:按容器比例重算视口 -Status: ready-for-agent +Status: resolved ## 问题 -viewport 以等比 "contain" 映射到画布:单轴贴边、另一轴多露出世界,因此现在的负 padding 只在目标比例下成立; -在超宽屏(3440×1440)或竖屏(1080×1920)下会露出多余的世界或裁掉主体。 +viewport 以等比 "contain" 映射到画布:单轴贴边、另一轴多露出世界。 +原本的判断是"负 padding 只在目标比例下成立,其它比例会裁掉主体"——**实测证明这个判断是错的**, +真正的病根是另一件事。 -## 步骤 +## 诊断(实测,非推测) -1. 测出每档壁纸在 21:9 / 16:9 / 9:16 下的构图偏差(已有对拍矩阵)。 -2. 按容器宽高比在运行时重算 pad(视口是活对象:原地改 pad 后调用 `player.setViewport(当前动画名)` 即生效, - 整体替换 viewport 会丢 `viewport.animations` 并在 spine-player 第 14973 行崩溃)。 -3. 目标:主体完整、底部对齐、无空边。 +先量清楚事实,再改代码: + +1. **contain 永远不会裁掉作者视口**。可见矩形是宽高比等于画布、且恰好含住作者视口 R 的最小矩形, + 所以 R 在**任何**比例下都完整可见。比例不同只是"另一轴多露出世界": + - 21:9 → 6453×2701(竖轴贴边、横轴多露) + - 16:9 → 6050×3403 + - 9:16 → 1914×3403(横轴贴边) + 三种比例下 `coversView` 全为 true,"裁掉作者视口"这条假设不成立。 +2. **这不是我重构引入的回归**。基线 `d8c7e49` 的 `preset-store.js` 里 xilian 的四个 pad + (-25% / -28% / -30% / -23%)与 kv37 的 `x: -1215, y: -410, w: 2048, h: 1024` 与现在一字不差; + 基线的容器也是 `#spine-container` 绝对定位铺满、body flex 居中。取景语义未变。 +3. **真正的病根:背景走 cover,立绘层走 contain,两者缩放分叉。** + `body { background-size: cover }` 会按需放大裁切,立绘层却按 contain 固定世界矩形。 + 以 1080×1920 竖屏为例,背景被放大约 1.78 倍,立绘层的世界高度却纹丝不动—— + 特效于是和背景插画脱节,"多余世界"其实看不太出来,脱节才看得出来。 +4. **spine 资源路径是 `./effects/*.json`:立绘层是特效层,背景插画才是主体。** + 所以判定标准不该是"特效不被裁切"(粒子飘出画面本来就正常), + 而应是"特效与背景插画始终同倍率、同中心"。 + +## 做法 + +新增 `dist/scripts/viewport-fitter.js`(纯函数)与 `SpineController` 的取景维护: + +- 参考比例 `16/9`:先让播放器自行解出作者视口 R,换算成参考比例下的可见矩形 V0。 + **这个比例下与作者设定逐像素一致**。 +- 其它比例:让立绘层的像素缩放**跟着背景的 cover 缩放走**—— + `V.h = V0.h × coverScale(参考比例) / coverScale(当前比例)`,中心不变。 + 于是背景放大 1.78 倍时,特效也放大 1.78 倍。 +- 应用方式:原地改写 `config.viewport` 的 x/y/width/height 与四个 pad,再调 + `player.setViewport(动画名)`,并把 `viewportTransitionStart` 推到足够久以前(避免 0.25s 插值拖影)。 + 绝不整体替换 `config.viewport`:`setViewport()` 会无条件读 `config.viewport.animations[name]`, + 换掉整个对象会在 spine-player 第 14973 行崩溃。 +- 维护时机:挂在 `config.frame` 回调上(它在每帧相机计算**之前**被调用),以画布比例作键—— + 比例没变就是一次浮点比较后返回,比例变了就地重算。 + `config.success` 用不了:它在 `setViewport` 之前触发,那时 `currentViewport` 还不存在。 +- 预设可用 `framing: "author"` 退回作者原始的 contain 行为。 ## 验收 -- 三种比例下都对拍留档,主体不被裁切、底部不悬空。 -- 切换窗口大小时不重建播放器(用 `setViewport`),连续 resize 无内存增长。 +| 项目 | 结果 | +| --- | --- | +| 参考比例回归 | kv37 **逐像素 0 差异**;xilian 仅 1/255(浮点末位舍入) | +| 立绘/背景缩放同步 | 21:9 / 16:9 / 16:10 / 9:16 × 两个预设,**全部精确相等**(离线算式验证 + 渲染实测) | +| 渲染实测吻合预测 | 三种比例可见矩形与公式预测逐值一致(pads 全 0、base == resolved) | +| 对拍留档 | `tools/shots/fit/` 下 6 组(2 预设 × 3 比例)改动前后各一套(该目录按仓库约定不入库,可随时重跑重生成) | +| 不重建播放器 | 9 次改视口 + 连续 20 次压力往返,实例身份标记**从未丢失** | +| 连续 resize 内存 | 强制 GC 后 7.9 MB → 8.0 MB(增长 **0.04 MB**) | +| 回到 16:9 可逆 | 取景完全回到原值,无漂移 | +| 控制台 | 0 报错 / 0 未捕获异常 | + +复现:`node .scratch/verify-fitter.mjs`(离线算式)、`node .scratch/test-resize.mjs`(resize 行为)。 + +## 附带修复 + +Chromium 会自动请求 `/favicon.ico` 并在控制台留下 404。已在 `dist/index.html` 加 +`` 消除,使"0 console 报错"这条验收真正可达成。 diff --git a/.scratch/wallpaper-optimization/issues/05-we-properties.md b/.scratch/wallpaper-optimization/issues/05-we-properties.md index 2b06bc6..61dc8e1 100644 --- a/.scratch/wallpaper-optimization/issues/05-we-properties.md +++ b/.scratch/wallpaper-optimization/issues/05-we-properties.md @@ -1,17 +1,40 @@ # 05 WE 属性扩展:音源 combo、fps 限制 -Status: ready-for-agent +Status: resolved ## 内容 -1. **音源(音源作用域已定)**:新增 `project.json` combo `bgm`,列表是所有壁纸音源声明的**并集**(WE 的 combo 是静态列表,只能手工同步); - 每档壁纸在自己 `preset.js` 的 `audioChoices` 里声明可选音源。切换壁纸时,若当前音源不属于新壁纸,回落"随预设"。 -2. **fps 限制**:`properties.fps` 经 `applyGeneralProperties` 下发,WE 不会替壁纸限流,需自行实现。 - 实现方式:包裹实例上的 `player.drawFrame`(循环用箭头闭包懒查,因此可以门控);注意 `config.frame/update/draw` - 都在下一帧排程之后运行,用它们限不了流。 -3. `setPaused` 暂不实现(WE 会整体冻结进程);除非用户在 WE 里实测发现音频仍在响。 +1. **音源**:`project.json` 新增 combo `bgm`,列表是所有壁纸音源声明的**并集** + (WE 的 combo 是静态列表,只能手工同步),另加一档「随预设」`auto`: + `auto` / `zaiduheni`「「再度和你」」/ `xilian`「昔涟」/ `pv37`「版本 PV」。 + 每档壁纸仍在自己 `preset.js` 的 `audioChoices` 里声明可选音源。 +2. **fps 限制**:`properties.fps` 经 `applyGeneralProperties` 下发,WE 不会替壁纸限流,自行实现。 +3. `setPaused` 不实现(WE 会整体冻结进程)。 + +## 实现要点 + +- **音源优先级**:用户自填 URL(`audio_file`)> `bgm` 选中的音源 > 本壁纸默认音源。 + 所选音源**只在本壁纸声明过时才生效**;不属于当前壁纸则音频回落本壁纸默认, + **选择本身保留**,切回那份壁纸时用户的选择还在。 +- **门控绘制**:包裹实例上的 `drawFrame`。播放器循环写作 + `requestAnimationFrame(() => this.drawFrame())`——箭头闭包在调用时才读 `this.drawFrame`, + 所以在实例上换掉它就能接管排程。 + - 原实现只在**真正绘制**时才排下一帧,而被限流丢掉的帧同样要排,否则循环停摆; + 因此排程由包装层负责,再让原实现以 `requestNextFrame = false` 调用。 + - **踩到的坑**:朴素地"距上一帧不足 `interval` 就丢"会把 rAF 抖动放大成整拍丢失—— + 目标 30fps 时落在 33.2ms 的那一帧被判为不足 33.33ms 而丢掉,实测只有 **22.5fps**。 + 改为"留 4ms 容差 + 到期时间按固定步长推进"后精确到 30.0。 + - **另一个坑**:`if (properties.fps)` 的真值判断会把 `fps: 0`(取消限流)整个吞掉, + 改判 `!== undefined`。 ## 验收 -- combo 切音源即时生效,且切壁纸时的回落行为正确。 -- 设置 30/60 fps 时实际帧率符合,且不影响动画相位推进速度。 +`node .scratch/test-props.mjs`: + +| 项目 | 结果 | +| --- | --- | +| bgm 切音源即时生效 | ✓(10 项音源断言全过) | +| 跨壁纸回落 + 选择保留 | ✓ xilian 选「昔涟」→ 切 kv37 回落 pv37 → 切回 xilian 回落默认 | +| 自定义 URL 优先级最高且不被切壁纸清掉 | ✓ | +| 限 30 / 20 / 取消限流 | 实测 **30.0 / 20.0 / 60.0 fps** | +| 不影响动画相位推进速度 | 0.999 / 0.999 / 1.000 倍速(基准 1.000) | diff --git a/.scratch/wallpaper-optimization/issues/06-package-v3.md b/.scratch/wallpaper-optimization/issues/06-package-v3.md index 9b0b24d..4fd5370 100644 --- a/.scratch/wallpaper-optimization/issues/06-package-v3.md +++ b/.scratch/wallpaper-optimization/issues/06-package-v3.md @@ -1,10 +1,34 @@ # 06 打包 v3 并重新发布 -Status: ready-for-agent +Status: resolved(第 4 步为人工步骤,待用户执行) -## 步骤 +## 步骤与结果 -1. `project.json`:`version` 升到 3,更新 `description`(提及新增音源切换等),核对 `properties` 与代码一致。 -2. `preview.gif` 现在 160×160 / 50 帧 / 840 KB——合规(≤256×256、≤1 MB)但超过推荐的 500 KB,重新压缩。 -3. 全量验收:0 死文件、≤35 MB、3 种比例 0 console 报错、预设往返不丢音源设置。 -4. 上传到 Workshop(人工步骤,需要用户操作)。 +1. **`project.json`**:`version` 升到 3;描述新增"可切换各预设自带的背景音乐""自适应窗口比例"; + 新增 `bgm` combo 与 `bgm_note`。核对:`preset` 的 combo 取值仍是 `xilian` / `kv37` + (用户已保存的设置依赖它,绝不能改)。**完成**。 +2. **`preview.gif`**:原 160×160 / 50 帧 / 25fps / 2.0s / **821 KB**,超推荐值 500 KB。 + 重新压缩命令(结果可复现): + + ```powershell + ffmpeg -y -i dist/preview.gif -filter_complex ` + "[0:v]fps=12.5,split[a][b];[a]palettegen=max_colors=128:stats_mode=full[p];[b][p]paletteuse=dither=none" ` + -loop 0 .scratch/pv-I.gif + Copy-Item .scratch/pv-I.gif dist/preview.gif -Force + ``` + + (**不能把输入输出写成同一个文件**,ffmpeg 会拒绝;必须先输出到临时文件再覆盖。) + 即"每 2 帧取 1 帧 + 128 色 + 不抖动",**逐帧与源帧像素完全相同**(第 k 帧对原第 2k 帧, + 抽查 0/6/12/18/24 帧全部 SSIM 1.0、max|diff| 0),只是时间采样率减半: + 160×160 / 25 帧 / 12.5fps / 2.0s / **347 KB**。**完成**。 + 注:减色到 96/64/48 色收效甚微(604/519/474 KB)——体积由帧间变化量主导,不由调色板主导, + 所以走帧采样率这条路更划算。 +3. **全量验收**(`node .scratch/test-acceptance.mjs`):**全部通过** + - 总体积 **28.52 MB** / 34 个文件(≤ 35 MB) + - **0 死文件** + - `project.json` 解析正常、version 3、bgm 存在、preset 取值未变 + - 21:9 / 16:9 / 9:16 三种比例:播放器就绪、可见矩形比例与画布一致、**0 控制台报错 / 0 异常** + - 预设往返 10 次:自定义音源与音量 0.2 均未丢失,仍在持续绘制,0 未捕获异常 +4. **上传到 Workshop**:人工步骤,需要用户操作。 + 本次为破坏性较小的重构(16:9 逐像素不变),但 21:9 / 9:16 下构图按设计改变,建议在 + 超宽屏与竖屏各看一眼再发布。 diff --git a/.scratch/wallpaper-optimization/spec.md b/.scratch/wallpaper-optimization/spec.md index b2060de..8b50dd0 100644 --- a/.scratch/wallpaper-optimization/spec.md +++ b/.scratch/wallpaper-optimization/spec.md @@ -35,7 +35,7 @@ | --- | --- | --- | | 01 | 资源按 游戏/壁纸 分层,预设拆进各自壁纸目录 | 完成(`09ff322`) | | 02 | 体积:音频转 Opus + 图片无损 WebP(105.1 → 28.9 MB) | 完成 | -| 03 | 健壮性:初始化状态机、死代码、用户级覆盖语义 | 待做 | -| 04 | 比例适配:按容器比例重算视口 | 待做 | -| 05 | WE 属性扩展:音源 combo、fps 限制 | 待做 | -| 06 | 打包 v3 并重新发布 | 待做 | +| 03 | 健壮性:初始化状态机、死代码、用户级覆盖语义 | 完成(`4662856`) | +| 04 | 比例适配:立绘层取景跟随背景 cover 缩放 | 完成 | +| 05 | WE 属性扩展:音源 combo、fps 限制 | 完成 | +| 06 | 打包 v3 并重新发布 | 完成(上传为待人工执行) | diff --git a/dist/index.html b/dist/index.html index 2d72e4b..b6461e6 100644 --- a/dist/index.html +++ b/dist/index.html @@ -4,6 +4,8 @@ 昔涟动态立绘 + + diff --git a/dist/preview.gif b/dist/preview.gif index 6c1c53d..c148053 100644 Binary files a/dist/preview.gif and b/dist/preview.gif differ diff --git a/dist/project.json b/dist/project.json index d9452e0..9675ede 100644 --- a/dist/project.json +++ b/dist/project.json @@ -1,6 +1,6 @@ { "contentrating" : "Everyone", - "description" : "[b]特色功能[/b]\r\n[list]\r\n [*]支持切换预设。\r\n [olist]\r\n [*]官方昔涟动态立绘。\r\n [*]官方三点七版本「成为昨日的明天」专题展示页。\r\n [/olist]\r\n [*]支持音源切换。\r\n [olist]\r\n [*][quote][b]注意:[/b]目前仅支持 URL 格式。[/quote]\r\n [/olist]\r\n[/list]", + "description" : "[b]特色功能[/b]\r\n[list]\r\n [*]支持切换预设。\r\n [olist]\r\n [*]官方昔涟动态立绘。\r\n [*]官方三点七版本「成为昨日的明天」专题展示页。\r\n [/olist]\r\n [*]支持音源切换。\r\n [olist]\r\n [*]可切换各预设自带的背景音乐。\r\n [*]也可自行填写音频 URL。\r\n [*][quote][b]注意:[/b]目前仅支持 URL 格式。[/quote]\r\n [/olist]\r\n [*]自适应窗口比例,不同显示器下构图保持一致。\r\n[/list]", "file" : "index.html", "general" : { @@ -49,6 +49,40 @@ "text" : "作者信息:
", "type" : "text" }, + "bgm" : + { + "index" : 8, + "options" : + [ + { + "label" : "随预设", + "value" : "auto" + }, + { + "label" : "「再度和你」", + "value" : "zaiduheni" + }, + { + "label" : "昔涟", + "value" : "xilian" + }, + { + "label" : "版本 PV", + "value" : "pv37" + } + ], + "order" : 108, + "text" : "🎵背景音乐选择", + "type" : "combo", + "value" : "auto" + }, + "bgm_note" : + { + "index" : 9, + "order" : 109, + "text" : "选择各预设自带的背景音乐:
", + "type" : "text" + }, "preset" : { "index" : 2, @@ -98,7 +132,7 @@ "tags" : [ "Anime" ], "title" : "【崩坏:星穹铁道】昔涟", "type" : "Web", - "version" : 2, + "version" : 3, "visibility" : "public", "workshopid" : "3604974793", "workshopurl" : "steam://url/CommunityFilePage/3604974793" diff --git a/dist/scripts/index.js b/dist/scripts/index.js index 642302a..8340e9e 100644 --- a/dist/scripts/index.js +++ b/dist/scripts/index.js @@ -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); } }, }; diff --git a/dist/scripts/spine-controller.js b/dist/scripts/spine-controller.js index 25ff0b2..307198c 100644 --- a/dist/scripts/spine-controller.js +++ b/dist/scripts/spine-controller.js @@ -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; } } diff --git a/dist/scripts/viewport-fitter.js b/dist/scripts/viewport-fitter.js new file mode 100644 index 0000000..80cc9fa --- /dev/null +++ b/dist/scripts/viewport-fitter.js @@ -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, + }; +} diff --git a/dist/scripts/wallpaper-engine.js b/dist/scripts/wallpaper-engine.js deleted file mode 100644 index 54a95cc..0000000 --- a/dist/scripts/wallpaper-engine.js +++ /dev/null @@ -1,974 +0,0 @@ -// 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/docs/adr/0003-spine-follows-background-cover.md b/docs/adr/0003-spine-follows-background-cover.md new file mode 100644 index 0000000..83c4842 --- /dev/null +++ b/docs/adr/0003-spine-follows-background-cover.md @@ -0,0 +1,36 @@ +# 立绘层取景跟随背景图的 cover 缩放 + +立绘层(spine 特效层)的可见世界矩形不再按"含住作者视口"(contain)映射, +而是让它与背景图 `background-size: cover` 的缩放**同步**:背景放大多少倍,立绘层就放大多少倍。 + +## 问题 + +背景走 CSS `cover`(铺满、按需裁切),立绘层走播放器自带的 contain 映射。 +两者只在作者调参的那个比例下一致,比例一变缩放就分叉。以 1080×1920 竖屏为例, +背景被放大约 1.78 倍,立绘层的世界高度却纹丝不动——特效与背景插画脱节。 + +需要说明的是:contain 本身**不会**裁掉作者视口。可见矩形是宽高比等于画布、 +且恰好含住作者视口的最小矩形,所以作者视口在任何比例下都完整可见。 +"负内边距在其它比例下会裁掉主体"这个最初的判断经实测证伪。 + +## 决定 + +- 参考比例取 `16/9`。该比例下取景与作者设定**逐像素一致**,保证已发布的观感不被改动。 +- 其它比例:`V.h = V0.h × coverScale(参考比例) / coverScale(当前比例)`,中心不变, + 其中 `V0` 是作者视口换算到参考比例后的可见矩形。等价于"立绘层相对背景插画永远 cover"。 +- 应用方式是原地改写 `config.viewport` 字段后调 `player.setViewport(动画名)`, + 绝不整体替换该对象:`setViewport()` 会无条件读 `config.viewport.animations[name]`, + 换掉整个对象会在 `spine-player.js` 第 14973 行崩溃。 +- 维护时机挂在 `config.frame` 回调(每帧相机计算之前),以画布比例作键。 + `config.success` 不可用:它在 `setViewport` 之前触发,那时 `currentViewport` 还不存在。 +- 预设可声明 `framing: "author"` 退回 contain 行为。 + +## 代价 + +21:9 与 9:16 下立绘层的可见范围会明显收窄(竖屏约收窄到 1/4 宽度), +因为要让特效跟着被放大 1.78 倍的背景走。这是刻意的:不这样做,特效就会与背景插画对不上。 +16:9 不受影响。若某档壁纸不适合这个策略,用 `framing: "author"` 单独退回。 + +## 背景图比例从哪来 + +运行时读图片的 `naturalWidth / naturalHeight`,不在预设里重复写一个容易写错的数字。 diff --git a/tools/ascii.mjs b/tools/ascii.mjs new file mode 100644 index 0000000..20c02d0 --- /dev/null +++ b/tools/ascii.mjs @@ -0,0 +1,109 @@ +// 把差分掩码(立绘层 = 正常帧 − 藏 canvas 参考帧)压成 ASCII, +// 这样在没有图像输入能力的情况下也能"看"清构图。 +// 用法: +// node tools/ascii.mjs [cols] 差分两幅,打印强度图 + 实体占比图 +// node tools/ascii.mjs [cols] 单幅,打印亮度图 +// 行数按 2.2:1 的字符高宽比自动换算,保证画面不被压扁。 + +import { spawn } from "node:child_process"; + +const flags = process.argv.slice(2).filter((a) => a.startsWith("--")); +const [fileA, fileB, colsArg] = process.argv.slice(2).filter((a) => !a.startsWith("--")); +const solidOnly = flags.includes("--solid"); +if (!fileA) { + console.error("usage: node tools/ascii.mjs [ref.png] [cols]"); + process.exit(2); +} + +function probe(file) { + return new Promise((resolve) => { + const p = spawn("ffprobe", ["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "csv=p=0", file]); + let out = ""; + p.stdout.on("data", (d) => (out += d)); + p.on("close", () => { + const [w, h] = out.trim().split(",").map(Number); + resolve({ w, h }); + }); + }); +} + +function gray(file) { + return new Promise((resolve, reject) => { + const p = spawn("ffmpeg", ["-v", "error", "-i", file, "-f", "rawvideo", "-pix_fmt", "gray", "-"], { stdio: ["ignore", "pipe", "pipe"] }); + const chunks = []; + let err = ""; + p.stdout.on("data", (d) => chunks.push(d)); + p.stderr.on("data", (d) => (err += d)); + p.on("close", (code) => (code === 0 ? resolve(Buffer.concat(chunks)) : reject(new Error(err || `exit ${code}`)))); + }); +} + +const RAMP = " .:-=+*#%@"; +const { w: W, h: H } = await probe(fileA); +const bufA = await gray(fileA); +if (bufA.length < W * H) { + console.error(`像素不足:${bufA.length} < ${W * H}`); + process.exit(1); +} + +// 终端字符高约为宽的 2.2 倍,据此把列数换算成行数,保持画面比例 +const cols = Math.max(20, Math.min(Number(colsArg) || 110, Math.floor((46 * 2.2 * W) / H))); +const rows = Math.max(6, Math.round((cols * H) / W / 2.2)); +const cw = W / cols; +const chh = H / rows; + +function grid(fn) { + const out = []; + for (let r = 0; r < rows; r++) { + let line = ""; + for (let c = 0; c < cols; c++) { + const x0 = Math.floor(c * cw); + const x1 = Math.min(W, Math.ceil((c + 1) * cw)); + const y0 = Math.floor(r * chh); + const y1 = Math.min(H, Math.ceil((r + 1) * chh)); + line += RAMP[Math.max(0, Math.min(9, fn(x0, x1, y0, y1)))]; + } + out.push(line); + } + return out; +} + +function print(title, lines) { + console.log(title); + for (const l of lines) console.log("|" + l + "|"); + console.log(""); +} + +console.log(`画布 ${W}x${H} → ${cols}列 x ${rows}行,单元 ${cw.toFixed(1)}x${chh.toFixed(1)}px;ramp "${RAMP}"`); + +if (!fileB) { + print(`亮度图 ${fileA}`, grid((x0, x1, y0, y1) => { + let s = 0; + let n = 0; + for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) { s += bufA[y * W + x]; n++; } + return Math.round((s / n / 255) * 9); + })); +} else { + const bufB = await gray(fileB); + if (bufB.length < W * H) { + console.error(`参考帧像素不足:${bufB.length} < ${W * H}`); + process.exit(1); + } + const d = new Uint8Array(W * H); + for (let i = 0; i < W * H; i++) d[i] = Math.abs(bufA[i] - bufB[i]); + + if (!solidOnly) + print(`立绘层 · 平均强度 (${fileA} − ${fileB})`, grid((x0, x1, y0, y1) => { + let s = 0; + let n = 0; + for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) { s += d[y * W + x]; n++; } + return Math.round((s / n / 255) * 9); + })); + + print(`立绘层 · 实体占比 (>96)`, grid((x0, x1, y0, y1) => { + let k = 0; + let n = 0; + for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) { if (d[y * W + x] > 96) k++; n++; } + return Math.round((k / n) * 9); + })); +} diff --git a/tools/frame.mjs b/tools/frame.mjs new file mode 100644 index 0000000..6d79256 --- /dev/null +++ b/tools/frame.mjs @@ -0,0 +1,54 @@ +// 量出一个预设在某分辨率下的取景几何,用来决定"该给什么视口"。 +// 输出:作者声明的取景矩形、播放器扫描出的骨架边界、当前相机实际可见的世界矩形。 +// 用法:node tools/frame.mjs + +import { withPage, sleep } from "./cdp.mjs"; + +const [port, preset, w, h] = process.argv.slice(2); +if (!port || !preset || !w || !h) { + console.error("usage: node tools/frame.mjs "); + process.exit(2); +} + +const EXPR = `(function () { + var p = window.__player; + if (!p || !p.skeleton) return { error: "player/skeleton 未就绪" }; + var cv = p.currentViewport; + var cam = p.sceneRenderer.camera; + var cw = p.canvas.width, ch = p.canvas.height; + // 用播放器自己的投影取真实可见世界矩形,不靠对 zoom 语义的猜测 + var tl = cam.screenToWorld(new spine.Vector3(0, 0, 0), cw, ch); + var br = cam.screenToWorld(new spine.Vector3(cw, ch, 0), cw, ch); + var offset = new spine.Vector2(), size = new spine.Vector2(); + p.skeleton.getBounds(offset, size, new Array(2)); + var vis = { x: tl.x, y: br.y, w: br.x - tl.x, h: tl.y - br.y }; + return { + canvas: { w: cw, h: ch, aspect: +(cw / ch).toFixed(4) }, + base: { x: +cv.x.toFixed(2), y: +cv.y.toFixed(2), w: +cv.width.toFixed(2), h: +cv.height.toFixed(2) }, + pads: { l: +cv.padLeft.toFixed(2), r: +cv.padRight.toFixed(2), b: +cv.padBottom.toFixed(2), t: +cv.padTop.toFixed(2) }, + resolved: { x: +(cv.x - cv.padLeft).toFixed(2), y: +(cv.y - cv.padBottom).toFixed(2), w: +(cv.width + cv.padLeft + cv.padRight).toFixed(2), h: +(cv.height + cv.padBottom + cv.padTop).toFixed(2) }, + visible: { x: +vis.x.toFixed(2), y: +vis.y.toFixed(2), w: +vis.w.toFixed(2), h: +vis.h.toFixed(2), aspect: +(vis.w / vis.h).toFixed(4) }, + skeletonBounds: { x: +offset.x.toFixed(2), y: +offset.y.toFixed(2), w: +size.x.toFixed(2), h: +size.y.toFixed(2) }, + skeletonInView: { + coversView: (offset.x <= vis.x && offset.y <= vis.y && offset.x + size.x >= vis.x + vis.w && offset.y + size.y >= vis.y + vis.h), + overflowLeft: +(vis.x - offset.x).toFixed(2), + overflowRight: +((offset.x + size.x) - (vis.x + vis.w)).toFixed(2), + overflowTop: +((offset.y + size.y) - (vis.y + vis.h)).toFixed(2), + overflowBottom: +(vis.y - offset.y).toFixed(2), + }, + }; +})()`; + +const result = await withPage({ width: Number(w), height: Number(h), profile: "tools/.cache/edge-frame" }, async ({ send, evaluate }) => { + const url = `http://127.0.0.1:${port}/index.html?__props=${encodeURIComponent(JSON.stringify({ preset }))}&__freeze=1`; + await send("Page.navigate", { url }); + for (let i = 0; i < 200; i++) { + if (await evaluate("!!(window.__player && window.__player.skeleton)")) break; + await sleep(150); + } + await sleep(400); + return evaluate(EXPR); +}); + +console.log(`${preset} @ ${w}x${h}`); +console.log(JSON.stringify(result, null, 2)); diff --git a/tools/subject.mjs b/tools/subject.mjs new file mode 100644 index 0000000..0e4c5e8 --- /dev/null +++ b/tools/subject.mjs @@ -0,0 +1,85 @@ +// 量出「立绘在屏幕上的实际足迹」:正常帧与藏 canvas 参考帧的差值掩码。 +// 不依赖对视口/相机数学的任何建模——直接测渲染结果。 +// 输出:足迹包围盒、四条边是否被画布切掉、覆盖面积占比、足迹底边距画布底部的距离。 +// 用法:node tools/subject.mjs [threshold] [edgeBand] + +import { spawn } from "node:child_process"; + +const [normal, ref, threshold = "8", edgeBand = "2"] = process.argv.slice(2); +if (!normal || !ref) { + console.error("usage: node tools/subject.mjs [threshold] [edgeBand]"); + process.exit(2); +} +const T = Number(threshold); +const BAND = Number(edgeBand); + +function probe(file) { + return new Promise((resolve) => { + const p = spawn("ffprobe", ["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "csv=p=0", file]); + let out = ""; + p.stdout.on("data", (d) => (out += d)); + p.on("close", () => { + const [w, h] = out.trim().split(",").map(Number); + resolve({ w, h }); + }); + }); +} + +const { w: W, h: H } = await probe(normal); + +const buf = await new Promise((resolve, reject) => { + const p = spawn("ffmpeg", ["-v", "error", "-i", normal, "-i", ref, "-lavfi", "blend=all_mode=difference,format=gray", "-f", "rawvideo", "-pix_fmt", "gray", "-"], { + stdio: ["ignore", "pipe", "pipe"], + }); + const chunks = []; + let err = ""; + p.stdout.on("data", (d) => chunks.push(d)); + p.stderr.on("data", (d) => (err += d)); + p.on("close", (code) => (code === 0 ? resolve(Buffer.concat(chunks)) : reject(new Error(err || `exit ${code}`)))); +}); + +if (buf.length < W * H) { + console.error(`差分流只返回 ${buf.length} 字节,期望 ${W * H}`); + process.exit(1); +} + +let minX = W, maxX = -1, minY = H, maxY = -1, count = 0; +const edge = { left: 0, right: 0, top: 0, bottom: 0 }; + +for (let y = 0; y < H; y++) { + for (let x = 0; x < W; x++) { + if (buf[y * W + x] <= T) continue; + count++; + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + if (x < BAND) edge.left++; + if (x >= W - BAND) edge.right++; + if (y < BAND) edge.top++; + if (y >= H - BAND) edge.bottom++; + } +} + +if (!count) { + console.log(`${normal} 没有超过阈值 ${T} 的像素——立绘层是空的?`); + process.exit(1); +} + +const pct = (n) => ((n / (W * H)) * 100).toFixed(3) + "%"; +console.log(`${normal} (${W}x${H}, 阈值 ${T})`); +console.log(` 足迹包围盒 x:[${minX}, ${maxX}] y:[${minY}, ${maxY}] 尺寸 ${maxX - minX + 1}x${maxY - minY + 1}`); +console.log(` 覆盖面积 ${count} 像素 (${pct(count)})`); +console.log(` 底部余量 画布底 ${H} - 足迹底 ${maxY} = ${H - 1 - maxY} px`); +console.log(` 顶部余量 ${minY} px`); +console.log(` 左右余量 左 ${minX} px / 右 ${W - 1 - maxX} px`); +console.log( + ` 贴边情况 ${[ + edge.left ? `左 ${edge.left}` : "左 -", + edge.right ? `右 ${edge.right}` : "右 -", + edge.top ? `上 ${edge.top}` : "上 -", + edge.bottom ? `下 ${edge.bottom}` : "下 -", + ].join(" ")} (${BAND}px 带内)`, +); +const cut = edge.left || edge.right || edge.top || edge.bottom; +console.log(` 判定 ${cut ? "被画布边缘切到" : "四边都未触边"}`); diff --git a/tools/wallpaper-engine-simulator.js b/tools/wallpaper-engine-simulator.js new file mode 100644 index 0000000..409c810 --- /dev/null +++ b/tools/wallpaper-engine-simulator.js @@ -0,0 +1,1589 @@ +// Wallpaper Engine 网页壁纸 API 模拟器(开发用具,**不随壁纸发布**) +// +// 为什么它不在 dist/ 里: +// 1. dist/ 只应包含壁纸真正加载的东西。它没有任何入口引用,属于死重。 +// 2. 更要紧的是它的启用判据不安全。isWallpaperEngineEnvironment() 要求 +// window.wallpaperRegisterListener 是函数,但**真实 Wallpaper Engine 里并没有这个 API** +// (已实测:WE 2.8.0.485 / CEF 146 只注入 window.wallpaperPropertyListener)。 +// 也就是说在真实 WE 中该判据会返回 false,模拟器会自我激活并覆盖真实 API。 +// 所以绝不能按下面的旧说明把它挂进 dist/index.html。 +// 3. 需要模拟环境时,用 tools/serve.mjs 的测试驱动(它注入的是可精确控制的桩)。 +// +// 保留它是为了在浏览器里手工调试 WE 属性与音频可视化,加载与否由使用者显式决定。 +// +// 原说明(已失效,勿照做):在 index.html 中以 +// +// 优先加载本脚本即可。若检测到真实的 Wallpaper Engine 环境则自动跳过。 +// 仅在非 Wallpaper Engine 环境下启用,尽可能还原原生处理方式 + +/* ============================================================ + * 环境检测 —— 多维度判断,避免误判 + * ============================================================ */ +function isWallpaperEngineEnvironment() { + if (typeof window.wallpaperRegisterListener !== 'function') return false; + if (typeof window.wallpaperPropertyListener !== 'object' || + window.wallpaperPropertyListener === null) return false; + + const nativeAPIs = [ + 'wallpaperRequestFile', + 'wallpaperGetUserDataPath', + 'wallpaperRegisterAudioListener' + ]; + const hasNativeAPI = nativeAPIs.some(api => typeof window[api] === 'function'); + if (!hasNativeAPI) return false; + + const ua = navigator.userAgent.toLowerCase(); + const isCEF = ua.includes('cef') || ua.includes('wallpaper'); + if (!isCEF) return false; + + return true; +} + +/* ============================================================ + * 工具函数 + * ============================================================ */ +const WEUtils = { + 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(''); + }, + + 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}`; + }, + + filePathToUrl(path) { + if (!path) return ''; + if (path.startsWith('file:///')) return path; + if (path.startsWith('http://') || path.startsWith('https://')) return path; + if (path.startsWith('blob:') || path.startsWith('data:')) return path; + let normalized = path.replace(/\\/g, '/'); + if (!normalized.startsWith('/')) normalized = '/' + normalized; + return 'file:///' + normalized.replace(/^\//, ''); + }, + + debounce(fn, wait = 150) { + let timer = null; + return function (...args) { + if (timer) clearTimeout(timer); + timer = setTimeout(() => fn.apply(this, args), wait); + }; + }, + + createFpsLimiter(fps) { + let lastTime = 0; + const interval = 1000 / Math.max(1, fps); + return function (callback) { + return function loop(timestamp) { + if (timestamp - lastTime >= interval) { + lastTime = timestamp; + callback(timestamp); + } + requestAnimationFrame(loop); + }; + }; + }, + + /** 生成稳定唯一 ID(用于 group 元素) */ + uid() { + return 'we_' + Math.random().toString(36).slice(2, 9); + } +}; + +/* ============================================================ + * 条件表达式求值器 + * ============================================================ */ +class ConditionEvaluator { + constructor(getPropertyValue) { + this.getPropertyValue = getPropertyValue; + this._cache = new Map(); + } + + evaluate(condition) { + if (!condition || typeof condition !== 'string') return true; + try { + const expr = condition.replace( + /([A-Za-z_$][\w$]*)\.value/g, + (_, key) => { + const v = this.getPropertyValue(key); + if (v === undefined || v === null) return 'undefined'; + if (typeof v === 'string') return JSON.stringify(v); + if (typeof v === 'boolean' || typeof v === 'number') { + return String(v); + } + return JSON.stringify(v); + } + ); + let fn = this._cache.get(expr); + if (!fn) { + fn = 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 = ''; + + // FPS + this._fps = 60; + this._originalRAF = null; + this._rafInstalled = false; + + // 音频 + this._audioContext = null; + this._audioAnalyser = null; + this._audioData = null; + this._audioLoopId = null; + + // DOM + this.panelEl = null; + this.styleEl = null; + this.fieldEls = new Map(); + this.groupEls = new Map(); + + // 目录文件追踪(用于 filesRemoved) + this._directoryFiles = new Map(); + + // 鼠标 + this._mouse = { + isOverPanel: false, + hideTimer: null, + EDGE_ZONE: 60, + HIDE_DELAY: 400 + }; + + // 持久化 key + this._storageKey = '__we_properties__'; + + this.conditionEvaluator = new ConditionEvaluator( + key => this.properties[key]?.value + ); + + this.initialize(); + } + + /* -------------------------------------------------------- + * 初始化 + * -------------------------------------------------------- */ + async initialize() { + try { + await this.loadProjectData(); + this.initializeProperties(); + this.restoreFromStorage(); + this.initializeAPI(); + this.setupMouseListeners(); + this.setupPauseListener(); + this.checkProtocol(); + this.checkCookieAvailability(); + 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) + }; + } + if (this.properties.schemecolor) { + const hex = WEUtils.colorToHex(this.properties.schemecolor.value); + document.documentElement.style.setProperty('--we-theme-color', hex); + } + } + + 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 'file': return ''; + case 'directory': return ''; + case 'texture': return ''; + case 'group': return null; + case 'usershortcut': return null; + case 'text': return ''; + default: return null; + } + } + + /* -------------------------------------------------------- + * localStorage 持久化 + * 模拟 WE 保存/恢复用户属性 + * -------------------------------------------------------- */ + restoreFromStorage() { + try { + const raw = localStorage.getItem(this._storageKey); + if (!raw) return; + const saved = JSON.parse(raw); + let restoredCount = 0; + for (const [key, value] of Object.entries(saved)) { + if (this.properties[key] && + this.propertyCache[key]?.type !== 'group' && + this.propertyCache[key]?.type !== 'usershortcut') { + this.properties[key].value = value; + restoredCount++; + } + } + if (restoredCount > 0) { + console.log( + '[WallpaperEngine] 已从 localStorage 恢复', + restoredCount, + '个属性值' + ); + } + // 更新主题色 + if (this.properties.schemecolor) { + const hex = WEUtils.colorToHex(this.properties.schemecolor.value); + document.documentElement.style.setProperty('--we-theme-color', hex); + } + } catch (e) { + console.warn('[WallpaperEngine] 恢复持久化属性失败:', e); + } + } + + saveToStorage(name, value) { + try { + const raw = localStorage.getItem(this._storageKey); + const saved = raw ? JSON.parse(raw) : {}; + saved[name] = value; + localStorage.setItem(this._storageKey, JSON.stringify(saved)); + } catch (e) { + // localStorage 可能被禁用或已满,静默忽略 + } + } + + clearStorage() { + try { + localStorage.removeItem(this._storageKey); + console.log('[WallpaperEngine] 已清除持久化属性'); + } catch (e) { /* ignore */ } + } + + /* -------------------------------------------------------- + * 协议 / Cookie 检测 + * -------------------------------------------------------- */ + checkProtocol() { + const proto = location.protocol; + if (proto === 'file:') { + console.log( + '[WallpaperEngine] 当前为 file:// 协议:' + + 'XMLHttpRequest 可以访问同目录文件,但 fetch 可能被阻止。' + ); + } else { + console.log( + '[WallpaperEngine] 当前为 ' + proto + ' 协议:' + + 'fetch / XHR 均可正常工作。' + + '若需读取本地文件,请通过 http 服务器运行。' + ); + } + } + + checkCookieAvailability() { + // Wallpaper Engine 的 CEF 容器中 document.cookie 通常不可用 + // 浏览器中可用,此处仅作说明 + try { + const testKey = '__we_cookie_test__'; + document.cookie = testKey + '=1; path=/'; + const available = document.cookie.includes(testKey + '=1'); + // 清理测试 cookie + document.cookie = testKey + '=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; + if (available) { + console.log( + '[WallpaperEngine] 浏览器中 document.cookie 可用;' + + '注意 WE 原生环境中不可用,请避免依赖 cookie。' + ); + } + } catch (e) { /* ignore */ } + } + + /* -------------------------------------------------------- + * 鼠标监听 + * -------------------------------------------------------- */ + 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); + }); + + document.addEventListener('keydown', e => { + if (e.key === 'Escape' && this.isPanelVisible) { + this.hideSettingsPanel(); + } + }); + } + + setupPauseListener() { + document.addEventListener('visibilitychange', () => { + const isPaused = document.hidden; + this._emit('setPaused', isPaused); + }); + } + + _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; + 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'); + } + + /* -------------------------------------------------------- + * 面板构造(含 group 折叠支持) + * -------------------------------------------------------- */ + 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(); + this.groupEls.clear(); + + // 当前激活的 group 容器 + let currentGroup = null; + + for (const { key, def } of sorted) { + // group 类型:创建折叠容器 + if (def.type === 'group') { + const group = document.createElement('details'); + group.className = 'we-group'; + group.open = true; + const gid = WEUtils.uid(); + group.dataset.groupKey = key; + + const summary = document.createElement('summary'); + summary.className = 'we-group__summary'; + // 支持 HTML + summary.innerHTML = def.text || key; + group.appendChild(summary); + + content.appendChild(group); + this.groupEls.set(key, group); + currentGroup = group; + continue; + } + + const row = this.createFieldRow(key, def); + if (row) { + (currentGroup || content).appendChild(row); + const ctrl = row.querySelector('[data-we-key]'); + if (ctrl) this.fieldEls.set(key, ctrl); + } + } + + // 底部工具条 + content.appendChild(this.buildToolsBar()); + + panel.appendChild(header); + panel.appendChild(content); + document.body.appendChild(panel); + this.panelEl = panel; + + this.refreshConditionalVisibility(); + } + + /** + * 底部工具条:清除持久化 / 音频可视化开关 + */ + buildToolsBar() { + const bar = document.createElement('div'); + bar.className = 'we-tools'; + + // 清除持久化 + const clearBtn = document.createElement('button'); + clearBtn.className = 'we-tool-btn'; + clearBtn.textContent = '清除持久化数据'; + clearBtn.title = '清除 localStorage 中保存的属性值并刷新'; + clearBtn.addEventListener('click', () => { + this.clearStorage(); + location.reload(); + }); + + // 音频可视化 + const audioBtn = document.createElement('button'); + audioBtn.className = 'we-tool-btn'; + audioBtn.textContent = this._audioContext ? '关闭音频可视化' : '启用音频可视化'; + audioBtn.title = '需要用户授权选择音频源'; + audioBtn.addEventListener('click', async () => { + if (this._audioContext) { + this.disableAudioVisualization(); + audioBtn.textContent = '启用音频可视化'; + } else { + await this.enableAudioVisualization(); + audioBtn.textContent = this._audioContext + ? '关闭音频可视化' + : '启用音频可视化'; + } + }); + + bar.appendChild(clearBtn); + bar.appendChild(audioBtn); + return bar; + } + + /* -------------------------------------------------------- + * 单属性控件 + * -------------------------------------------------------- */ + 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'; + 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); + case 'file': return this.buildFileRow(row, key, def, label); + case 'directory': return this.buildDirectoryRow(row, key, def, label); + case 'texture': return this.buildTextureRow(row, key, def, label); + case 'usershortcut': return this.buildUserShortcutRow(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'; + 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); + + 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; + } + + buildFileRow(row, key, def, label) { + label.htmlFor = key; + row.appendChild(label); + + const wrap = document.createElement('div'); + wrap.className = 'we-file-wrap'; + + const input = document.createElement('input'); + input.type = 'file'; + input.className = 'we-file-input'; + input.id = key; + input.dataset.weKey = key; + input.accept = def.fileType || ''; + + const display = document.createElement('div'); + display.className = 'we-file-display'; + display.textContent = this.properties[key]?.value || '未选择文件'; + + input.addEventListener('change', () => { + const file = input.files[0]; + if (!file) return; + const url = URL.createObjectURL(file); + display.textContent = file.name; + this.updateProperty(key, url); + }); + + wrap.appendChild(input); + wrap.appendChild(display); + row.appendChild(wrap); + return row; + } + + buildDirectoryRow(row, key, def, label) { + label.htmlFor = key; + row.appendChild(label); + + const wrap = document.createElement('div'); + wrap.className = 'we-file-wrap'; + + const input = document.createElement('input'); + input.type = 'file'; + input.className = 'we-file-input'; + input.id = key; + input.dataset.weKey = key; + input.webkitdirectory = true; + input.multiple = true; + + const display = document.createElement('div'); + display.className = 'we-file-display'; + display.textContent = this.properties[key]?.value || '未选择目录'; + + input.addEventListener('change', () => { + const files = Array.from(input.files); + if (files.length === 0) return; + const dirPath = files[0].webkitRelativePath.split('/')[0]; + display.textContent = `${dirPath} (${files.length} 个文件)`; + this.updateProperty(key, dirPath); + + // 触发 AddedOrChanged / Removed 事件 + const newFileList = files.map(f => f.name); + const oldFileList = this._directoryFiles.get(key) || []; + + const added = newFileList.filter(f => !oldFileList.includes(f)); + const removed = oldFileList.filter(f => !newFileList.includes(f)); + + if (added.length > 0) { + this._emitUserDirectoryEvent( + 'userDirectoryFilesAddedOrChanged', key, added + ); + } + if (removed.length > 0) { + this._emitUserDirectoryEvent( + 'userDirectoryFilesRemoved', key, removed + ); + } + this._directoryFiles.set(key, newFileList); + }); + + wrap.appendChild(input); + wrap.appendChild(display); + row.appendChild(wrap); + return row; + } + + /** + * texture 类型:图像/视频纹理 + * 浏览器降级:使用 + ObjectURL + * WE 原生:返回 file:/// 路径 + */ + buildTextureRow(row, key, def, label) { + label.htmlFor = key; + row.appendChild(label); + + const wrap = document.createElement('div'); + wrap.className = 'we-file-wrap'; + + const input = document.createElement('input'); + input.type = 'file'; + input.className = 'we-file-input'; + input.id = key; + input.dataset.weKey = key; + input.accept = 'image/*,video/*'; + + const display = document.createElement('div'); + display.className = 'we-file-display'; + const currentValue = this.properties[key]?.value || ''; + display.textContent = currentValue + ? decodeURIComponent(currentValue.split('/').pop() || '') + : '未选择纹理'; + + // 小预览 + const preview = document.createElement('div'); + preview.className = 'we-texture-preview'; + + input.addEventListener('change', () => { + const file = input.files[0]; + if (!file) return; + const url = URL.createObjectURL(file); + display.textContent = file.name; + preview.innerHTML = ''; + if (file.type.startsWith('video/')) { + const v = document.createElement('video'); + v.src = url; + v.muted = true; + v.autoplay = true; + v.loop = true; + preview.appendChild(v); + } else { + const img = document.createElement('img'); + img.src = url; + img.alt = file.name; + preview.appendChild(img); + } + this.updateProperty(key, url); + }); + + wrap.appendChild(input); + wrap.appendChild(display); + wrap.appendChild(preview); + row.appendChild(wrap); + return row; + } + + /** + * usershortcut 类型:用户定义的快捷方式 + * Web 壁纸无法触发,仅作只读展示 + */ + buildUserShortcutRow(row, key, def, label) { + label.htmlFor = key; + row.appendChild(label); + + const info = document.createElement('div'); + info.className = 'we-field__text'; + info.textContent = '用户快捷方式(仅 SceneScript 可用,Web 壁纸中不可触发)'; + info.style.fontStyle = 'italic'; + info.style.color = '#7a7a88'; + row.appendChild(info); + 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)) { + // group 元素的条件 + const groupEl = this.groupEls.get(key); + if (groupEl && def.condition) { + const visible = this.conditionEvaluator.evaluate(def.condition); + groupEl.classList.toggle('we-field--hidden', !visible); + } + // 普通字段 + 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.saveToStorage(name, value); + + // 通知监听器 + 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 + ); + } + } + } + + /* -------------------------------------------------------- + * FPS 限制:包装全局 requestAnimationFrame + * -------------------------------------------------------- */ + installGlobalFpsLimiter(fps) { + if (this._rafInstalled) return; + this._originalRAF = window.requestAnimationFrame; + const interval = 1000 / Math.max(1, fps); + let lastTime = 0; + const originalRAF = this._originalRAF; + + window.requestAnimationFrame = function (callback) { + return originalRAF.call(window, function (timestamp) { + if (timestamp - lastTime >= interval) { + lastTime = timestamp; + try { + callback(timestamp); + } catch (err) { + console.error( + '[WallpaperEngine] RAF 回调抛错:', err + ); + } + } + // 注意:这里不再循环调用 RAF + // 由调用者自己决定是否继续请求下一帧 + }); + }; + + this._rafInstalled = true; + console.log('[WallpaperEngine] 全局 RAF 已限制为', fps, 'fps'); + } + + uninstallGlobalFpsLimiter() { + if (!this._rafInstalled || !this._originalRAF) return; + window.requestAnimationFrame = this._originalRAF; + this._rafInstalled = false; + console.log('[WallpaperEngine] 全局 RAF 已恢复'); + } + + setFpsLimit(fps) { + this._fps = fps; + if (fps > 0 && fps < 1000) { + this.installGlobalFpsLimiter(fps); + } + } + + /* -------------------------------------------------------- + * 样式 + * -------------------------------------------------------- */ + 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; +} + +/* group 折叠容器 */ +.we-group { + margin-bottom: 20px; + border: 1px solid rgba(255,255,255,0.08); + border-radius: 6px; + padding: 8px 12px 0; + background: rgba(255,255,255,0.02); +} +.we-group__summary { + cursor: pointer; + font-size: 13px; + font-weight: 600; + color: #e6e6ee; + padding: 6px 0 8px; + user-select: none; + list-style: none; + position: relative; + padding-left: 18px; +} +.we-group__summary::-webkit-details-marker { display: none; } +.we-group__summary::before { + content: "▸"; + position: absolute; + left: 0; + top: 6px; + transition: transform 0.2s; + color: var(--we-theme-color); +} +.we-group[open] > .we-group__summary::before { + transform: rotate(90deg); +} +.we-group > .we-field:last-child { + margin-bottom: 12px; +} + +/* 开关 */ +.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-file-wrap { + display: flex; + flex-direction: column; + gap: 6px; +} +.we-file-input { + display: none; +} +.we-file-display { + padding: 9px 11px; + background: rgba(255,255,255,0.06); + border: 1px dashed rgba(255,255,255,0.2); + border-radius: 6px; + font-size: 13px; + color: #9a9aa8; + cursor: pointer; + text-align: center; + transition: border-color 0.15s, color 0.15s; + word-break: break-all; +} +.we-file-display:hover { + border-color: var(--we-theme-color); + color: #f0f0f5; +} +.we-texture-preview { + border-radius: 6px; + overflow: hidden; + max-height: 120px; + display: flex; + justify-content: center; + background: rgba(0,0,0,0.2); +} +.we-texture-preview:empty { display: none; } +.we-texture-preview img, +.we-texture-preview video { + max-width: 100%; + max-height: 120px; + display: block; +} + +/* 底部工具条 */ +.we-tools { + margin-top: 24px; + padding-top: 16px; + border-top: 1px solid rgba(255,255,255,0.08); + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.we-tool-btn { + flex: 1; + min-width: 120px; + padding: 8px 12px; + background: rgba(255,255,255,0.06); + border: 1px solid rgba(255,255,255,0.12); + border-radius: 6px; + color: #e6e6ee; + font-size: 12px; + font-family: inherit; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} +.we-tool-btn:hover { + background: rgba(255,255,255,0.1); + border-color: var(--we-theme-color); + color: #fff; +} + +/* 滚动条 */ +.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); +} + `; + } + + /* -------------------------------------------------------- + * 全局 API 注入 + * -------------------------------------------------------- */ + initializeAPI() { + if (!window.wallpaperPropertyListener) { + window.wallpaperPropertyListener = {}; + } + + 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); + } + }; + } + + if (!window.wallpaperGetUserDataPath) { + window.wallpaperGetUserDataPath = () => this.userDataPath; + } + + if (!window.wallpaperRequestFile) { + window.wallpaperRequestFile = (filename) => { + console.warn( + '[WallpaperEngine] wallpaperRequestFile 在浏览器中不可用:', + filename + ); + return null; + }; + } + + if (!window.wallpaperRegisterAudioListener) { + window.wallpaperRegisterAudioListener = (callback) => { + if (typeof callback !== 'function') return; + this.audioListeners.push({ applyAudioProperties: callback }); + }; + } + + 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) { + let lists; + switch (method) { + case 'applyGeneralProperties': + lists = this.generalListeners; + break; + case 'applyUserProperties': + lists = this.propertyListeners; + break; + case 'setPaused': + lists = [...this.generalListeners, ...this.propertyListeners]; + break; + default: + lists = this.propertyListeners; + } + for (const listener of lists) { + try { + listener[method]?.(payload); + } catch (err) { + console.error( + `[WallpaperEngine] ${method} 抛错:`, err + ); + } + } + } + + _emitUserDirectoryEvent(eventName, propertyName, files) { + const lists = [...this.generalListeners, ...this.propertyListeners]; + for (const listener of lists) { + try { + listener[eventName]?.(propertyName, files); + } catch (err) { + console.error( + `[WallpaperEngine] ${eventName} 抛错:`, err + ); + } + } + } + + /* -------------------------------------------------------- + * 音频可视化 + * -------------------------------------------------------- */ + async enableAudioVisualization() { + if (this._audioContext) return; + try { + const stream = await navigator.mediaDevices.getDisplayMedia({ + video: true, + audio: true + }); + this._audioContext = new AudioContext(); + const source = this._audioContext.createMediaStreamSource(stream); + this._audioAnalyser = this._audioContext.createAnalyser(); + this._audioAnalyser.fftSize = 256; + source.connect(this._audioAnalyser); + this._audioData = new Uint8Array( + this._audioAnalyser.frequencyBinCount + ); + + const updateInterval = 1000 / 30; + let lastUpdate = 0; + const update = (timestamp) => { + if (timestamp - lastUpdate >= updateInterval) { + lastUpdate = timestamp; + this._audioAnalyser.getByteFrequencyData(this._audioData); + const normalized = Array.from(this._audioData).map( + v => Math.min(1, v / 255) + ); + const left = normalized.slice(0, 64); + const right = normalized.slice(64, 128); + const audioArray = [...left, ...right]; + for (const listener of this.audioListeners) { + try { + listener.applyAudioProperties?.(audioArray); + } catch (err) { + console.error( + '[WallpaperEngine] 音频回调抛错:', err + ); + } + } + } + this._audioLoopId = requestAnimationFrame(update); + }; + this._audioLoopId = requestAnimationFrame(update); + console.log('[WallpaperEngine] 音频可视化已启用'); + } catch (err) { + console.warn( + '[WallpaperEngine] 音频可视化启用失败(需要用户授权):', + err + ); + } + } + + disableAudioVisualization() { + if (this._audioLoopId) { + cancelAnimationFrame(this._audioLoopId); + this._audioLoopId = null; + } + if (this._audioContext) { + this._audioContext.close(); + this._audioContext = null; + this._audioAnalyser = null; + this._audioData = null; + } + console.log('[WallpaperEngine] 音频可视化已关闭'); + } + + /* -------------------------------------------------------- + * 初始属性推送 + * WE 行为:加载时先 applyGeneralProperties,再逐条 applyUserProperties + * -------------------------------------------------------- */ + simulateWallpaperEngine() { + const run = () => { + setTimeout(() => { + // 1. 通用属性(FPS) + this.setFpsLimit(60); + this._emit('applyGeneralProperties', { fps: 60 }); + + // 2. 逐条推送用户属性 + for (const [key, prop] of Object.entries(this.properties)) { + const def = this.propertyCache[key]; + // group / usershortcut 不推送 + if (def?.type === 'group' || def?.type === 'usershortcut') { + continue; + } + this._emit('applyUserProperties', { + [key]: { value: prop.value } + }); + } + console.log('[WallpaperEngine] 已推送初始属性到监听器'); + }, 100); + }; + + if (document.readyState === 'complete') run(); + else window.addEventListener('load', run, { once: true }); + } +} + +/* ============================================================ + * 启动 + * ============================================================ */ +function bootstrap() { + if (isWallpaperEngineEnvironment()) { + console.log( + '[WallpaperEngine] 检测到原生 Wallpaper Engine 环境,跳过模拟器' + ); + return; + } + console.log('[WallpaperEngine] 未检测到原生环境,启动模拟器'); + + const simulator = new WallpaperEngineSimulator(); + + const check = setInterval(() => { + if (simulator.isInitialized) { + clearInterval(check); + simulator.simulateWallpaperEngine(); + console.log('[WallpaperEngine] 模拟环境已启动'); + } + }, 50); + setTimeout(() => clearInterval(check), 5000); + + // 调试入口 + window.__WE_SIMULATOR__ = simulator; + window.__WE_ENABLE_AUDIO__ = () => simulator.enableAudioVisualization(); + window.__WE_DISABLE_AUDIO__ = () => simulator.disableAudioVisualization(); + window.__WE_CLEAR_STORAGE__ = () => simulator.clearStorage(); + window.__WE_SET_FPS__ = (fps) => simulator.setFpsLimit(fps); +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', bootstrap, { once: true }); +} else { + bootstrap(); +} + +export default { + isWallpaperEngineEnvironment, + WallpaperEngineSimulator, + WEUtils +}; \ No newline at end of file