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

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

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

202 lines
8.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 零依赖静态服务器:把 dist/ 以正确的 MIME 暴露出来,供无头 Edge 与人工本地预览使用。
// 用法:node tools/serve.mjs [port] [--root <dir>]
//
// 它同时承担"测试台"的角色,且完全不改动 dist/:
// ?nojs=1 剥掉所有 <script>,得到"只有 CSS 背景、没有 canvas"的对照组
// ?__props={...} 模拟 Wallpaper Engine 下发用户属性,如 {"preset":"kv37","audio_volume":0.2}
// ?__propsAt=load 默认在 window.load 之后下发(正常路径)
// =dom 则在 DOMContentLoaded 时下发,复现"WE 在 load 之前就推属性"的时序
// ?__freeze=1 骨骼加载完成后暂停播放并把动画相位归零,使截图逐像素可复现
// ?__hide=canvas 渲染正常但把 canvas 藏起来,得到"只有背景图"的同款参考帧
//
// 驱动脚本会把状态写进 document.title,配合 shot.mjs --dom 即可断言。
import { createServer } from "node:http";
import { createReadStream } from "node:fs";
import { readFile, stat } from "node:fs/promises";
import { extname, join, normalize, resolve, sep } from "node:path";
const args = process.argv.slice(2);
const portArg = args.find((a) => /^\d+$/.test(a));
const rootFlag = args.indexOf("--root");
const root = resolve(rootFlag >= 0 ? args[rootFlag + 1] : "dist");
const port = Number(portArg ?? 8181);
const MIME = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".atlas": "text/plain; charset=utf-8",
".txt": "text/plain; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
".svg": "image/svg+xml",
".flac": "audio/flac",
".mp3": "audio/mpeg",
".ogg": "audio/ogg",
".opus": "audio/ogg",
".wav": "audio/wav",
".webm": "video/webm",
};
// 注入到 <script type="module" src="scripts/index.js"> 之前:
// 这是唯一能"在 spine-player.js 已加载、而 index.js 尚未执行"的插入点。
const DRIVER = (q) => `<script>
(function () {
var q = new URLSearchParams(location.search);
var props = {};
try { props = JSON.parse(q.get("__props") || "{}"); } catch (e) { props = {}; }
var freeze = q.get("__freeze") === "1";
var propsAt = q.get("__propsAt") || "load";
var state = { patched: false, presets: Object.keys(props), propsAt: null, successAt: null, frames: null, error: null };
window.__test = state;
if (!window.spine || !window.spine.SpinePlayer) {
state.error = "spine.SpinePlayer missing";
} else {
// 注意:spine-player.js 用 __export 把导出定义成 getter-only,
// 直接给 spine.SpinePlayer 赋值在非严格模式下会静默失败,所以只能补 prototype。
var proto = window.spine.SpinePlayer.prototype;
var origValidate = proto.validateConfig;
proto.validateConfig = function (config) {
var prevSuccess = config.success;
var prevError = config.error;
config.success = function (p) {
window.__player = p;
state.successAt = Math.round(performance.now());
if (freeze) freezePlayer(p);
if (prevSuccess) prevSuccess(p);
};
config.error = function (p, msg) { state.error = String(msg); if (prevError) prevError(p, msg); };
state.patched = true;
return origValidate.call(this, config);
};
var origInitialize = proto.initialize;
proto.initialize = function () {
window.__player = this;
return origInitialize.apply(this, arguments);
};
var origDraw = proto.drawFrame;
proto.drawFrame = function () {
state.drawCalls = (state.drawCalls || 0) + 1;
// 冻结只做一次是不够的:相位可能在成功回调前后被推进过一帧。这里每帧都强制归零,
// 保证"同一份代码的任何两次截图"是同一相位,对拍才有意义。
if (freeze) {
try {
this.paused = true;
this.speed = 0;
var cur = this.animationState && this.animationState.getCurrent(0);
if (cur) cur.trackTime = 0;
if (this.skeleton) this.skeleton.updateWorldTransform(2);
this.viewportTransitionStart = performance.now() - 1e6;
} catch (e) { state.error = "freeze-frame: " + e; }
}
return origDraw.apply(this, arguments);
};
}
function freezePlayer(p) {
try {
// 冻结姿势。关键在 paused=true:drawFrame 里 playDelta = paused ? 0 : delta * speed,
// 于是 animationState.update(0) 不会推进相位。只把 trackTime 置 0 是不够的——
// 那是在播放器 update(playDelta) 之前设的,渲染出来的仍是 t=playDelta 的姿势,
// 而 playDelta 随帧时长浮动,会留下"边缘亚像素抖动"式的伪差异。
p.paused = true;
p.speed = 0;
var cur = p.animationState && p.animationState.getCurrent(0);
if (cur) cur.trackTime = 0;
if (p.skeleton) p.skeleton.updateWorldTransform(2);
// 视口过渡(transitionTime 默认 0.25s)是时间相关的插值,会把"冻结"变得不彻底:
// 把它推到足够久以前,transitionAlpha >= 1,直接使用 currentViewport。
p.viewportTransitionStart = performance.now() - 1e6;
state.info = {
animations: p.skeleton ? p.skeleton.data.animations.map(function (a) { return a.name; }) : [],
skin: p.skeleton ? p.skeleton.data.skins.map(function (s) { return s.name; }) : [],
playerError: p.error ? String(p.error) : null,
};
if (q.get("__hide") === "canvas" && p.canvas) p.canvas.style.visibility = "hidden";
} catch (e) { state.error = "freeze: " + e; }
}
function pushProps() {
if (!Object.keys(props).length) return;
var listener = window.wallpaperPropertyListener;
if (!listener || !listener.applyUserProperties) { state.error = "wallpaperPropertyListener missing"; return; }
var payload = {};
Object.keys(props).forEach(function (k) { payload[k] = { value: props[k] }; });
listener.applyUserProperties(payload);
state.propsAt = Math.round(performance.now());
}
if (propsAt === "dom") {
document.addEventListener("DOMContentLoaded", pushProps);
} else {
window.addEventListener("load", function () { setTimeout(pushProps, 0); });
}
var ticks = 0;
var timer = setInterval(function () {
ticks++;
if (window.__player) state.frames = window.__player.time ? Math.round(window.__player.time.frames) : state.frames;
document.title = "TESTSTATE " + JSON.stringify(state);
if (ticks > 40) clearInterval(timer);
}, 250);
})();
</script>`;
async function html(body, query) {
let out = body;
if (query.get("nojs") === "1") out = out.replace(/<script\b[^>]*><\/script>/g, "");
else if (query.has("__props") || query.has("__freeze")) {
out = out.replace(/(<script\b[^>]*type="module"[^>]*>)/, DRIVER(query) + "$1");
}
return out;
}
const server = createServer(async (req, res) => {
try {
const url = new URL(req.url, "http://127.0.0.1");
let rel = decodeURIComponent(url.pathname);
if (rel.endsWith("/")) rel += "index.html";
const abs = normalize(join(root, rel));
if (abs !== root && !abs.startsWith(root + sep)) {
res.writeHead(403).end("forbidden");
return;
}
const info = await stat(abs).catch(() => null);
if (!info || !info.isFile()) {
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" }).end(`404 ${rel}`);
console.log(`404 ${rel}`);
return;
}
if (extname(abs).toLowerCase() === ".html") {
const body = await html(await readFile(abs, "utf8"), url.searchParams);
res.writeHead(200, {
"content-type": "text/html; charset=utf-8",
"content-length": Buffer.byteLength(body),
"cache-control": "no-store",
});
res.end(body);
return;
}
res.writeHead(200, {
"content-type": MIME[extname(abs).toLowerCase()] ?? "application/octet-stream",
"content-length": info.size,
"cache-control": "no-store",
});
createReadStream(abs).pipe(res);
} catch (err) {
res.writeHead(500, { "content-type": "text/plain; charset=utf-8" }).end(String(err));
}
});
server.listen(port, "127.0.0.1", () => {
console.log(`serving ${root}`);
console.log(` http://127.0.0.1:${port}/`);
});