Files
mini-viewer/pages/viewer/viewer.js
T

661 lines
19 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.
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,
_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: true })
.exec((res) => {
const hit = res && res[0]
if (!hit || !hit.node) {
this.setData({
status: 'error',
errorMsg: '取不到 canvas 节点,请确认基础库支持 <canvas type="webgl">',
})
return
}
const canvas = hit.node
const size = hit.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('#spineCanvas')
.fields({ node: true, size: true })
.exec((res) => {
const hit = res && res[0]
if (!hit || !hit.node || !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()
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)
// 深色画布背景(与 UI 的深色预览区保持一致)
gl.clearColor(0.12, 0.13, 0.16, 1)
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 }
}
},
})