微信小程序-省心预览工具

This commit is contained in:
Yeomai committed 2026-09-28 21:23:52 +08:00
commit edf1e51846
173 files changed
+78282

No files matched your search

+660
View File
@@ -0,0 +1,660 @@
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 }
}
},
})