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 / 属性 / 全量验收三套浏览器测试脚本。
This commit is contained in:
1 parent
4662856963
commit
eb56efbd9e
22 files changed
+2766
-1012
No files matched your search
Vendored
+127
-3
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user