Files
SpineWallpaper/dist/scripts/spine-controller.js
T
Shuery eb56efbd9e feat: 比例适配、bgm 音源选择与 fps 限流,打包 v3
取景(issue 04)
- 新增 viewport-fitter.js:让立绘层缩放跟随背景图的 cover 缩放——背景放大多少倍、
  特效就放大多少倍。原先背景走 cover、立绘层走 contain,比例一变两者就分叉。
  16:9 下与作者设定逐像素一致(kv37 为 0 差异)。实测证伪了"contain 会裁掉作者视口"
  这个最初的判断:contain 在任何比例下都完整含住作者视口。
- 应用方式为原地改写 config.viewport 字段后调 setViewport(),不重建播放器;
  维护挂在 config.frame 上按画布比例增量进行(config.success 早于 setViewport,拿不到视口)。

WE 属性(issue 05)
- project.json 新增 bgm combo(所有壁纸音源的并集 + 「随预设」);所选项不属于当前壁纸时
  音频回落该壁纸默认,选择本身保留。
- fps 限流包裹实例 drawFrame 并接管排程。修掉两个实测缺陷:朴素丢帧会把 rAF 抖动放大成
  整拍丢失(目标 30fps 实测只有 22.5fps);fps:0 被真值判断吞掉导致无法取消限流。

打包(issue 06)
- project.json version 升至 3 并更新描述;preset 的 combo 取值保持 xilian/kv37 不变。
- preview.gif 821 KB → 347 KB(每 2 帧取 1 帧 + 128 色不抖动,逐帧与源帧像素完全相同)。
- index.html 加内联 favicon,消除 CEF 自动请求 /favicon.ico 造成的 404 报错。

其它
- dist/scripts/wallpaper-engine.js 移到 tools/:它没有任何入口引用,且环境判据依赖真实 WE
  并不存在的 wallpaperRegisterListener,一旦按它自己的说明挂进 index.html 会在生产环境自我激活。
- 修正死文件检查:原检查会拿文件名匹配到文件自身的内容,使这类文件漏网。
- 新增离线算式验证与 resize / 属性 / 全量验收三套浏览器测试脚本。
2026-09-19 20:29:25 +08:00

175 lines
7.0 KiB
JavaScript
Raw 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.
import { REFERENCE_ASPECT, contain, frameForAspect, resolveViewport, toViewportConfig } from "./viewport-fitter.js";
// 负责把「当前预设的 spineConfig」应用到 Spine 播放器上,并维持立绘层与背景图的取景同步。
//
// 核心约定一:写入与应用分开。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;
this.baseConfig = {
alpha: true,
premultipliedAlpha: false,
showControls: false,
showLoading: false,
...baseConfig,
};
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 现算,避免上一份预设的键残留。
// 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);
};
}
// 换骨架没有捷径,只能销毁重建
rebuild() {
this.dispose();
this.create();
}
dispose() {
if (!this.player) return;
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;
}
}