微信小程序-省心预览工具
This commit is contained in:
commit
edf1e51846
173 files changed
+78282
No files matched your search
@@ -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 }
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"navigationBarTitleText": "Spine 预览",
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<view class="page">
|
||||
|
||||
<!-- ===== 预览区 ===== -->
|
||||
<view class="stage">
|
||||
<canvas
|
||||
id="spineCanvas"
|
||||
class="stage-canvas"
|
||||
type="webgl"
|
||||
bindtouchstart="onTouchStart"
|
||||
bindtouchmove="onTouchMove"
|
||||
bindtouchend="onTouchEnd"
|
||||
bindtouchcancel="onTouchEnd"
|
||||
></canvas>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<view wx:if="{{status === 'idle'}}" class="stage-mask">
|
||||
<view class="empty-icon">🎬</view>
|
||||
<view class="empty-title">Spine 动画预览</view>
|
||||
<view class="empty-sub">支持 Spine 4.2 导出的 .skel / .atlas / .png</view>
|
||||
<view class="empty-tip">把三个文件发到「文件传输助手」后在下方选择;真机可一次多选,PC 可分多次选</view>
|
||||
</view>
|
||||
|
||||
<!-- 加载中 -->
|
||||
<view wx:if="{{status === 'loading'}}" class="stage-mask">
|
||||
<view class="empty-icon">⏳</view>
|
||||
<view class="empty-sub">{{statusText}}</view>
|
||||
</view>
|
||||
|
||||
<!-- 出错 -->
|
||||
<view wx:if="{{status === 'error'}}" class="stage-mask">
|
||||
<view class="empty-icon">⚠️</view>
|
||||
<view class="empty-title error">加载失败</view>
|
||||
<view class="empty-sub">{{errorMsg}}</view>
|
||||
</view>
|
||||
|
||||
<!-- 右上角信息徽标 -->
|
||||
<view wx:if="{{status === 'ready'}}" class="stage-badge">{{info}}</view>
|
||||
</view>
|
||||
|
||||
<!-- ===== 控制面板 ===== -->
|
||||
<view class="sheet">
|
||||
<view class="grabber"></view>
|
||||
|
||||
<!-- 文件 -->
|
||||
<view class="section">
|
||||
<view class="section-title">文件</view>
|
||||
|
||||
<button class="pick-btn" bindtap="chooseFiles">
|
||||
<text class="pick-icon">📁</text>
|
||||
<text class="pick-text">选择文件</text>
|
||||
<text class="pick-hint">可多选</text>
|
||||
</button>
|
||||
|
||||
<view wx:if="{{pickedCount}}" class="chips">
|
||||
<view wx:for="{{pickedFiles}}" wx:key="id" class="chip chip-{{item.kind}}">
|
||||
<text class="chip-dot"></text>
|
||||
<text class="chip-name">{{item.name}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{missingText}}" class="missing">● {{missingText}}</view>
|
||||
|
||||
<view wx:if="{{pickedCount}}" class="link" bindtap="clearFiles">清空已选</view>
|
||||
</view>
|
||||
|
||||
<!-- 播放 -->
|
||||
<view wx:if="{{animations.length}}" class="section">
|
||||
<view class="section-title">播放</view>
|
||||
|
||||
<picker mode="selector" range="{{animations}}" value="{{animIndex}}" bindchange="onAnimChange">
|
||||
<view class="select">
|
||||
<text class="select-label">{{animations[animIndex]}}</text>
|
||||
<text class="select-arrow">▾</text>
|
||||
</view>
|
||||
</picker>
|
||||
|
||||
<view class="segment">
|
||||
<view class="seg {{playing ? 'on' : ''}}" bindtap="togglePlay">{{playing ? '⏸ 暂停' : '▶ 播放'}}</view>
|
||||
<view class="seg {{loop ? 'on' : ''}}" bindtap="toggleLoop">🔁 循环</view>
|
||||
<view class="seg {{premultiplied ? 'on' : ''}}" bindtap="togglePremultiplied">✨ 预乘</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 参数 -->
|
||||
<view wx:if="{{animations.length}}" class="section">
|
||||
<view class="section-title">参数</view>
|
||||
|
||||
<view class="param">
|
||||
<text class="param-name">速度</text>
|
||||
<slider
|
||||
class="param-slider"
|
||||
min="0.1" max="3" step="0.1" value="{{speed}}"
|
||||
activeColor="#4a6cf7" backgroundColor="#e9ecf5" block-color="#4a6cf7" block-size="16"
|
||||
bindchange="onSpeedChange"
|
||||
/>
|
||||
<text class="param-val">{{speed}}x</text>
|
||||
</view>
|
||||
|
||||
<view class="param">
|
||||
<text class="param-name">缩放</text>
|
||||
<view class="stepper">
|
||||
<view class="step-btn" bindtap="zoomOut">-</view>
|
||||
<text class="step-val">{{scalePercent}}%</text>
|
||||
<view class="step-btn" bindtap="zoomIn">+</view>
|
||||
</view>
|
||||
<view class="link inline" bindtap="resetView">重置</view>
|
||||
</view>
|
||||
|
||||
<view class="gesture-hint">单指拖动平移 · 双指捏合缩放</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
</view>
|
||||
@@ -0,0 +1,324 @@
|
||||
/* ===== 整体 ===== */
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: #16181d;
|
||||
}
|
||||
|
||||
/* ===== 预览区(深色)===== */
|
||||
.stage {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: linear-gradient(180deg, #21252d 0%, #14161b 100%);
|
||||
}
|
||||
|
||||
.stage-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.stage-mask {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48rpx;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 76rpx;
|
||||
margin-bottom: 22rpx;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
margin-bottom: 14rpx;
|
||||
}
|
||||
|
||||
.empty-title.error {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.empty-sub {
|
||||
font-size: 26rpx;
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
margin-top: 20rpx;
|
||||
font-size: 23rpx;
|
||||
color: rgba(255, 255, 255, 0.38);
|
||||
line-height: 1.6;
|
||||
max-width: 560rpx;
|
||||
}
|
||||
|
||||
.stage-badge {
|
||||
position: absolute;
|
||||
left: 22rpx;
|
||||
top: 22rpx;
|
||||
padding: 8rpx 18rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(0, 0, 0, 0.34);
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
/* ===== 控制面板(白色圆角卡片)===== */
|
||||
.sheet {
|
||||
background: #ffffff;
|
||||
border-radius: 32rpx 32rpx 0 0;
|
||||
padding: 0 28rpx 28rpx;
|
||||
box-shadow: 0 -10rpx 36rpx rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.grabber {
|
||||
width: 76rpx;
|
||||
height: 8rpx;
|
||||
border-radius: 4rpx;
|
||||
background: #e3e6ee;
|
||||
margin: 14rpx auto 18rpx;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 26rpx;
|
||||
}
|
||||
|
||||
.section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 23rpx;
|
||||
color: #9aa1b1;
|
||||
font-weight: 600;
|
||||
letter-spacing: 2rpx;
|
||||
margin-bottom: 14rpx;
|
||||
}
|
||||
|
||||
/* ===== 选择文件按钮 ===== */
|
||||
.pick-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 94rpx;
|
||||
padding: 0;
|
||||
border-radius: 16rpx;
|
||||
background: linear-gradient(135deg, #4a6cf7 0%, #6d8bff 100%);
|
||||
color: #ffffff;
|
||||
font-size: 30rpx;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 8rpx 20rpx rgba(74, 108, 247, 0.3);
|
||||
}
|
||||
|
||||
.pick-btn::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.pick-icon {
|
||||
font-size: 32rpx;
|
||||
margin-right: 12rpx;
|
||||
}
|
||||
|
||||
.pick-text {
|
||||
font-size: 30rpx;
|
||||
}
|
||||
|
||||
.pick-hint {
|
||||
margin-left: 14rpx;
|
||||
padding: 4rpx 14rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
font-size: 21rpx;
|
||||
}
|
||||
|
||||
/* ===== 文件类型标签 ===== */
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 12rpx 12rpx 0;
|
||||
padding: 8rpx 18rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #f3f5f9;
|
||||
}
|
||||
|
||||
.chip-dot {
|
||||
width: 12rpx;
|
||||
height: 12rpx;
|
||||
border-radius: 50%;
|
||||
background: #94a3b8;
|
||||
margin-right: 10rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chip-name {
|
||||
font-size: 23rpx;
|
||||
color: #4a5264;
|
||||
}
|
||||
|
||||
.chip-skel .chip-dot {
|
||||
background: #4a6cf7;
|
||||
}
|
||||
|
||||
.chip-atlas .chip-dot {
|
||||
background: #7c5cff;
|
||||
}
|
||||
|
||||
.chip-img .chip-dot {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.chip-json .chip-dot {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.missing {
|
||||
margin-top: 2rpx;
|
||||
font-size: 24rpx;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.link {
|
||||
margin-top: 14rpx;
|
||||
font-size: 24rpx;
|
||||
color: #9aa1b1;
|
||||
}
|
||||
|
||||
.link.inline {
|
||||
margin-top: 0;
|
||||
margin-left: 18rpx;
|
||||
color: #4a6cf7;
|
||||
}
|
||||
|
||||
/* ===== 动画选择 ===== */
|
||||
.select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 86rpx;
|
||||
padding: 0 26rpx;
|
||||
margin-bottom: 14rpx;
|
||||
background: #f5f6fa;
|
||||
border-radius: 14rpx;
|
||||
}
|
||||
|
||||
.select-label {
|
||||
font-size: 29rpx;
|
||||
font-weight: 500;
|
||||
color: #1f2430;
|
||||
}
|
||||
|
||||
.select-arrow {
|
||||
font-size: 26rpx;
|
||||
color: #9aa1b1;
|
||||
}
|
||||
|
||||
/* ===== 分段控件 ===== */
|
||||
.segment {
|
||||
display: flex;
|
||||
padding: 6rpx;
|
||||
background: #f2f4f8;
|
||||
border-radius: 14rpx;
|
||||
}
|
||||
|
||||
.seg {
|
||||
flex: 1;
|
||||
height: 68rpx;
|
||||
line-height: 68rpx;
|
||||
text-align: center;
|
||||
font-size: 25rpx;
|
||||
color: #7b8290;
|
||||
border-radius: 10rpx;
|
||||
}
|
||||
|
||||
.seg.on {
|
||||
background: #ffffff;
|
||||
color: #4a6cf7;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* ===== 参数行 ===== */
|
||||
.param {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.param-name {
|
||||
width: 76rpx;
|
||||
flex-shrink: 0;
|
||||
font-size: 26rpx;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.param-slider {
|
||||
flex: 1;
|
||||
margin: 0 16rpx;
|
||||
}
|
||||
|
||||
.param-val {
|
||||
width: 76rpx;
|
||||
flex-shrink: 0;
|
||||
text-align: right;
|
||||
font-size: 25rpx;
|
||||
font-weight: 600;
|
||||
color: #4a6cf7;
|
||||
}
|
||||
|
||||
/* ===== 缩放步进器 ===== */
|
||||
.stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 16rpx;
|
||||
background: #f2f4f8;
|
||||
border-radius: 12rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.step-btn {
|
||||
width: 70rpx;
|
||||
height: 64rpx;
|
||||
line-height: 64rpx;
|
||||
text-align: center;
|
||||
font-size: 32rpx;
|
||||
color: #4a5264;
|
||||
}
|
||||
|
||||
.step-btn:active {
|
||||
background: #e6e9f2;
|
||||
}
|
||||
|
||||
.step-val {
|
||||
min-width: 104rpx;
|
||||
text-align: center;
|
||||
font-size: 25rpx;
|
||||
font-weight: 600;
|
||||
color: #1f2430;
|
||||
}
|
||||
|
||||
.gesture-hint {
|
||||
margin-top: 4rpx;
|
||||
text-align: center;
|
||||
font-size: 22rpx;
|
||||
color: #b6bcc9;
|
||||
}
|
||||
Reference in new issue
Block a user