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; } }