初始化竞态(已实证):旧 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)。
202 lines
8.3 KiB
JavaScript
202 lines
8.3 KiB
JavaScript
// 零依赖静态服务器:把 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}/`);
|
||
});
|