const spine = require('../../vendor/spine.js') const env = require('../../utils/spine-env.js') const core = spine.core const webgl = spine.webgl const IMAGE_RE = /\.(png|jpg|jpeg|webp)$/i Page({ data: { status: 'idle', // idle | loading | ready | error statusText: '', errorMsg: '', animations: [], animIndex: 0, playing: true, loop: true, premultiplied: true, speed: 1, scalePercent: 100, info: '', pickedText: '', pickedFiles: [], pickedCount: 0, missingText: '', }, // ---- 非响应式运行时字段 ---- _canvas: null, _gl: null, _managed: null, _shader: null, _batcher: null, _renderer: null, _skeleton: null, _state: null, _skeletonData: null, _pma: false, _dpr: 1, _baseScale: 1, _viewScale: 1, _tx: 0, _ty: 0, _fitOffset: null, _fitSize: null, _lastSizeCheck: 0, _lastTime: 0, _raf: null, _ready: false, _touch: null, _pinch: null, _picked: [], onReady() { env.installWebGLShim() this.initCanvas() // 首帧布局可能尚未稳定,稍后重新同步一次画布尺寸,避免画面比例被拉伸 setTimeout(() => this.syncCanvasSize(), 150) }, onResize() { if (this._resizeTimer) clearTimeout(this._resizeTimer) this._resizeTimer = setTimeout(() => this.syncCanvasSize(), 100) }, onHide() { this.stopLoop() }, onShow() { if (this._ready) this.startLoop() }, onUnload() { this.stopLoop() this._ready = false }, // ---------------- 初始化 WebGL ---------------- initCanvas() { wx.createSelectorQuery() .in(this) .select('#spineCanvas') .fields({ node: true }) // 画布节点返回的 size 是缓冲区尺寸(不是当前布局尺寸), // 所以量外面这层普通 view 来定缓冲区大小,否则布局一变画面就会被拉伸 .select('#stageBox') .fields({ size: true }) .exec((res) => { const canvasHit = res && res[0] const stageHit = res && res[1] if (!canvasHit || !canvasHit.node) { this.setData({ status: 'error', errorMsg: '取不到 canvas 节点,请确认基础库支持 ', }) return } const canvas = canvasHit.node const size = (stageHit && stageHit.size) || { width: 300, height: 300 } let dpr = 1 try { const info = wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync() dpr = info.pixelRatio || 1 } catch (e) { dpr = 1 } this._dpr = dpr const w = Math.max(1, Math.floor((size.width || 300) * dpr)) const h = Math.max(1, Math.floor((size.height || 300) * dpr)) canvas.width = w canvas.height = h let gl = null try { gl = canvas.getContext('webgl', { alpha: true, antialias: true, premultipliedAlpha: true }) } catch (e) { gl = null } if (!gl) { try { gl = canvas.getContext('webgl') } catch (e) { gl = null } } if (!gl) { this.setData({ status: 'error', errorMsg: '当前环境不支持 WebGL,无法预览(请更新微信或基础库)', }) return } try { this._canvas = canvas this._gl = gl this._managed = env.wrapGLContext(webgl, gl, w, h) this._shader = webgl.Shader.newTwoColoredTextured(this._managed) this._batcher = new webgl.PolygonBatcher(this._managed) this._renderer = new webgl.SkeletonRenderer(this._managed) } catch (e) { this.setData({ status: 'error', errorMsg: 'WebGL 初始化失败:' + ((e && e.message) || String(e)), }) return } this.setData({ status: 'idle' }) this.syncCanvasSize() }) }, /** * 同步画布缓冲区尺寸到当前显示尺寸(含 dpr)。 * 面板内容变化会让画布显示区域改变,若不重新同步, * 缓冲区与显示区比例不一致就会导致画面被拉伸/挤压。 */ syncCanvasSize() { if (!this._canvas || !this._gl) return wx.createSelectorQuery() .in(this) .select('#stageBox') .fields({ size: true }) .exec((res) => { const hit = res && res[0] if (!hit || !hit.size) return const size = hit.size if (!size.width || !size.height) return const w = Math.max(1, Math.round(size.width * this._dpr)) const h = Math.max(1, Math.round(size.height * this._dpr)) if (this._canvas.width === w && this._canvas.height === h) return this._canvas.width = w this._canvas.height = h // 尺寸变化后重新适配,保证画面比例与缩放正确 if (this._fitOffset && this._fitSize) { this._baseScale = this.computeFitScale() this.centerView() } }) }, // ---------------- 选择并加载文件(支持多选 / 多次累积)---------------- chooseFiles() { wx.chooseMessageFile({ count: 20, type: 'all', success: (res) => { this.mergeFiles(res.tempFiles || []) }, fail: (err) => { const msg = (err && err.errMsg) || '' if (msg.indexOf('cancel') >= 0) return this.setData({ status: 'error', errorMsg: '选择文件失败:' + (msg || '未知错误') }) }, }) }, // 按扩展名分类 classify(files) { const lower = (n) => String(n || '').toLowerCase() return { skel: files.find((f) => lower(f.name).endsWith('.skel')) || null, json: files.find((f) => lower(f.name).endsWith('.json')) || null, atlas: files.find((f) => lower(f.name).endsWith('.atlas')) || null, images: files.filter((f) => IMAGE_RE.test(lower(f.name))), } }, // 累积已选文件:既支持一次多选,也支持分多次挑选(自动去重) mergeFiles(incoming) { const picked = this._picked || [] incoming.forEach((f) => { const dup = picked.some((p) => p.name === f.name && p.size === f.size) if (!dup) picked.push({ name: f.name, path: f.path, size: f.size }) }) this._picked = picked this.refreshPickedUI() }, refreshPickedUI() { const picked = this._picked || [] const has = this.classify(picked) const missing = [] if (!has.atlas) missing.push('.atlas') if (!has.skel && !has.json) missing.push('.skel') if (!has.images.length) missing.push('.png') this.setData({ pickedText: picked.map((f) => f.name).join('、'), pickedFiles: picked.map((f, index) => { const n = String(f.name || '').toLowerCase() let kind = 'other' if (n.endsWith('.skel')) kind = 'skel' else if (n.endsWith('.json')) kind = 'json' else if (n.endsWith('.atlas')) kind = 'atlas' else if (IMAGE_RE.test(n)) kind = 'img' return { id: index, name: f.name, kind: kind } }), pickedCount: picked.length, missingText: missing.length ? '还缺少:' + missing.join(' ') : '', }) // 面板高度变化会改变画布显示区域,需重新同步画布尺寸 setTimeout(() => this.syncCanvasSize(), 80) // 三件套齐全才自动加载;否则提示还缺什么 // (PC/模拟器端一次只能选一个文件,可分多次继续选) if (!missing.length) { this.loadFromFiles(picked) } else { wx.showToast({ title: '已添加,还缺 ' + missing.join(' ') + ',可再次选择', icon: 'none', duration: 2500, }) } }, clearFiles() { this.stopLoop() this._ready = false this._picked = [] this.setData({ pickedText: '', pickedFiles: [], pickedCount: 0, missingText: '', animations: [], info: '', status: 'idle', errorMsg: '', }) // 面板收起后画布显示区会变高,重新同步尺寸 setTimeout(() => this.syncCanvasSize(), 80) }, async loadFromFiles(files) { if (!this._gl) { this.setData({ status: 'error', errorMsg: '画布尚未就绪,请稍后重试' }) return } if (!files || !files.length) { this.setData({ status: 'error', errorMsg: '没有选中任何文件' }) return } const has = this.classify(files) const skelFile = has.skel const jsonFile = has.json const atlasFile = has.atlas const imageFiles = has.images if (!atlasFile) { this.setData({ status: 'error', errorMsg: '缺少 .atlas 图集文件' }) return } if (!skelFile && !jsonFile) { this.setData({ status: 'error', errorMsg: '缺少 .skel 骨骼文件(也支持 .json)' }) return } if (!imageFiles.length) { this.setData({ status: 'error', errorMsg: '缺少 .png 贴图文件' }) return } this.setData({ status: 'loading', statusText: '读取文件中…' }) try { const fs = wx.getFileSystemManager() const atlasText = fs.readFileSync(atlasFile.path, 'utf8') const skelBuffer = skelFile ? fs.readFileSync(skelFile.path) : null const jsonText = jsonFile ? fs.readFileSync(jsonFile.path, 'utf8') : null this.setData({ statusText: '加载贴图…' }) const images = [] for (let i = 0; i < imageFiles.length; i++) { images.push(await this.loadImage(imageFiles[i])) } this.setData({ statusText: '解析骨骼数据…' }) const atlas = new core.TextureAtlas(atlasText) // 把贴图绑定到图集页(按文件名匹配,匹配不到则用第一张) atlas.pages.forEach((page) => { const pageName = env.baseName(page.name) const match = images.find((im) => env.baseName(im.name) === pageName) || images[0] const tex = new webgl.GLTexture(this._managed, match.img) tex.setFilters(page.minFilter, page.magFilter) page.setTexture(tex) }) const loader = new core.AtlasAttachmentLoader(atlas) let skeletonData if (skelBuffer) { const bin = new core.SkeletonBinary(loader) bin.scale = 1 skeletonData = bin.readSkeletonData(new Uint8Array(skelBuffer)) } else { const sj = new core.SkeletonJson(loader) skeletonData = sj.readSkeletonData(JSON.parse(jsonText)) } const skeleton = new core.Skeleton(skeletonData) const stateData = new core.AnimationStateData(skeletonData) const state = new core.AnimationState(stateData) const animations = skeletonData.animations.map((a) => a.name) if (!animations.length) { this.setData({ status: 'error', errorMsg: '该骨骼文件里没有可播放的内容' }) return } this._skeletonData = skeletonData this._skeleton = skeleton this._state = state this._pma = atlas.pages.length ? !!atlas.pages[0].pma : false this._renderer.premultipliedAlpha = this._pma // 用 setup pose 计算一次包围盒,用于自动适配画布 skeleton.setToSetupPose() skeleton.updateWorldTransform(core.Physics.update) const offset = new core.Vector2() const size = new core.Vector2() skeleton.getBounds(offset, size) this._fitOffset = { x: offset.x, y: offset.y } this._fitSize = { x: size.x, y: size.y } this._baseScale = this.computeFitScale() this._viewScale = 1 this.centerView() state.setAnimation(0, animations[0], this.data.loop) this._ready = true this.setData({ status: 'ready', animations, animIndex: 0, playing: true, scalePercent: 100, info: 'Spine ' + skeletonData.version + ' · ' + skeletonData.bones.length + '骨骼 / ' + skeletonData.slots.length + '插槽 · ' + animations.length + '段画面 · ' + atlas.pages.length + '图集页 · 文件声明PMA ' + (this._pma ? '是' : '否'), }) // 播放控件出现会改变画布显示区域,重新同步尺寸以保证比例正确 setTimeout(() => this.syncCanvasSize(), 80) this.startLoop() } catch (e) { this.setData({ status: 'error', errorMsg: '加载失败:' + ((e && e.message) || String(e)), }) } }, loadImage(file) { return new Promise((resolve, reject) => { if (!this._canvas || typeof this._canvas.createImage !== 'function') { reject(new Error('当前 canvas 不支持 createImage')) return } const img = this._canvas.createImage() img.onload = () => resolve({ name: file.name, img: img }) img.onerror = () => reject(new Error('贴图加载失败:' + file.name)) img.src = file.path }) }, // ---------------- 视图计算 ---------------- computeFitScale() { const W = this._canvas.width const H = this._canvas.height const sx = Math.max(this._fitSize.x, 1) const sy = Math.max(this._fitSize.y, 1) return Math.min((W * 0.9) / sx, (H * 0.9) / sy) }, centerView() { const W = this._canvas.width const H = this._canvas.height this._tx = W / 2 - (this._fitOffset.x + this._fitSize.x / 2) * this._baseScale this._ty = H / 2 - (this._fitOffset.y + this._fitSize.y / 2) * this._baseScale }, // ---------------- 渲染循环 ---------------- startLoop() { if (this._raf) return this._lastTime = Date.now() const step = () => { this._raf = null if (!this._ready) return this.renderFrame() const canvas = this._canvas if (canvas && typeof canvas.requestAnimationFrame === 'function') { this._raf = canvas.requestAnimationFrame(step) } else { this._raf = setTimeout(step, 16) } } step() }, stopLoop() { if (!this._raf) return const canvas = this._canvas if (canvas && typeof canvas.cancelAnimationFrame === 'function') { canvas.cancelAnimationFrame(this._raf) } else { clearTimeout(this._raf) } this._raf = null }, renderFrame() { const canvas = this._canvas const gl = this._gl if (!canvas || !gl || !this._skeleton) return const now = Date.now() // 面板内容变化(比如加载后多出播放/参数区)会改变画布显示尺寸。 // 只在加载后校对一次是不够的:那次可能跑在布局生效之前,之后就再也不会纠正, // 缓冲区与显示区比例不一致就会让画面被拉伸。这里定期校对,尺寸没变时直接返回,开销很小。 if (now - this._lastSizeCheck > 400) { this._lastSizeCheck = now this.syncCanvasSize() } let dt = (now - this._lastTime) / 1000 this._lastTime = now if (!(dt > 0)) dt = 0 if (dt > 0.1) dt = 0.1 if (this.data.playing) { this._state.update(dt * this.data.speed) this._state.apply(this._skeleton) } this._skeleton.updateWorldTransform(core.Physics.update) const W = canvas.width const H = canvas.height const scale = this._baseScale * this._viewScale const mvp = env.buildMVP(scale, this._tx, this._ty, W, H) gl.viewport(0, 0, W, H) // 透明清屏:canvas 是原生组件、层级在 WebView 之上, // 用不透明颜色清屏会盖住压在它上面的浮层(缩放按钮、手势提示、信息徽标)。 // 背景交给舞台的 CSS 渐变,观感一致,但浮层能正常显示。 gl.clearColor(0, 0, 0, 0) gl.clear(gl.COLOR_BUFFER_BIT) // 预乘开关:同时影响渲染器的混色方式与 GL 混合函数 const pma = this.data.premultiplied this._renderer.premultipliedAlpha = pma gl.enable(gl.BLEND) gl.disable(gl.DEPTH_TEST) if (pma) { gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA) } else { gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA) } const shader = this._shader shader.bind() shader.setUniformi(webgl.Shader.SAMPLER, 0) shader.setUniform4x4f(webgl.Shader.MVP_MATRIX, mvp) const batcher = this._batcher batcher.begin(shader) this._renderer.draw(batcher, this._skeleton) batcher.end() shader.unbind() }, // ---------------- 交互控制 ---------------- onAnimChange(e) { const idx = Number(e.detail.value) const name = this.data.animations[idx] this.setData({ animIndex: idx }) if (this._state && name) { this._state.setAnimation(0, name, this.data.loop) } }, togglePlay() { this.setData({ playing: !this.data.playing }) }, toggleLoop() { const loop = !this.data.loop this.setData({ loop }) const name = this.data.animations[this.data.animIndex] if (this._state && name) { this._state.setAnimation(0, name, loop) } }, togglePremultiplied() { this.setData({ premultiplied: !this.data.premultiplied }) }, onSpeedChange(e) { this.setData({ speed: Number(e.detail.value) }) }, zoomIn() { this.zoomBy(1.25) }, zoomOut() { this.zoomBy(0.8) }, zoomBy(factor) { const prev = this._viewScale const next = this.clampScale(prev * factor) if (next === prev) return // 以画布中心为锚点缩放 const W = this._canvas ? this._canvas.width : 0 const H = this._canvas ? this._canvas.height : 0 this.applyZoomAnchor(next / prev, W / 2, H / 2) this._viewScale = next this.updateScalePercent() }, resetView() { if (!this._fitOffset) return this._viewScale = 1 this.centerView() this.setData({ scalePercent: 100 }) }, distance(a, b) { const dx = b.x - a.x const dy = b.y - a.y return Math.sqrt(dx * dx + dy * dy) }, clampScale(v) { return Math.min(8, Math.max(0.1, v)) }, // 以画布像素点 (X, Y) 为锚点缩放:该点下的世界坐标保持不动 applyZoomAnchor(k, X, Y) { this._tx = X * (1 - k) + k * this._tx this._ty = Y * (1 - k) + k * this._ty }, updateScalePercent() { const pct = Math.round(this._viewScale * 100) if (pct !== this.data.scalePercent) { this.setData({ scalePercent: pct }) } }, onTouchStart(e) { const ts = e.touches || [] if (ts.length >= 2) { // 进入双指缩放:记录初始指距与当时的缩放值 this._touch = null this._pinch = { dist0: this.distance(ts[0], ts[1]), scale0: this._viewScale, } return } if (ts.length === 1) { this._pinch = null this._touch = { x: ts[0].x, y: ts[0].y } } }, onTouchMove(e) { const ts = e.touches || [] // 双指捏合缩放 if (ts.length >= 2) { if (!this._pinch || !this._pinch.dist0) return const dist = this.distance(ts[0], ts[1]) if (dist <= 0) return const prev = this._viewScale const next = this.clampScale(this._pinch.scale0 * (dist / this._pinch.dist0)) if (next === prev) return // 以双指中点(画布像素坐标)为锚点缩放 const H = this._canvas ? this._canvas.height : 0 const mx = (ts[0].x + ts[1].x) / 2 const my = (ts[0].y + ts[1].y) / 2 const X = mx * this._dpr const Y = H - my * this._dpr this.applyZoomAnchor(next / prev, X, Y) this._viewScale = next this.updateScalePercent() return } // 单指平移 if (!this._touch || ts.length !== 1) return const t = ts[0] const dx = t.x - this._touch.x const dy = t.y - this._touch.y this._touch = { x: t.x, y: t.y } // 屏幕 y 向下,画布 y 向上,所以 dy 取反 this._tx += dx * this._dpr this._ty -= dy * this._dpr }, onTouchEnd(e) { const ts = (e && e.touches) || [] if (ts.length < 2) this._pinch = null if (ts.length === 0) { this._touch = null } else if (ts.length === 1) { // 从双指变为单指时,重置平移基准,避免画面跳变 this._touch = { x: ts[0].x, y: ts[0].y } } }, })