- assets/<游戏>/<壁纸>/{effects,images,audios}/,每档壁纸自包含
- 每档壁纸自带 preset.js:用 import.meta.url 推导自身资源路径,目录可整体搬走;
目录名用显示名(含「」),代码键用稳定 id(xilian/kv37,与 WE 属性值绑定)
- preset-store.js -> presets.js:静态 import 两份预设并建 id 索引
- 新增 CONTEXT.md 术语表与 ADR-0001/0002
- 新增 check-paths / compare / heatmap 三个验证工具;serve.mjs 改为每帧强制动画相位归零
验证:check-paths 11/11 路径存在(含此前零引用的 昔涟.flac);浏览器 0 个真实 404;
与基线 worktree 同冻结设置对拍,9 对跨版本比较中 4 对逐像素完全相同(差异 0),
其余 ≤0.19/255,远低于基线自身的双模态抖动(2.27/255)。
195 lines
7.8 KiB
JavaScript
195 lines
7.8 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 {
|
||
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 {
|
||
p.pause();
|
||
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}/`);
|
||
});
|