Files
2026-09-29 01:50:11 +08:00

678 lines
20 KiB
JavaScript
Raw Permalink 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.
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 节点,请确认基础库支持 <canvas type="webgl">',
})
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 }
}
},
})