661 lines
19 KiB
JavaScript
661 lines
19 KiB
JavaScript
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 }
|
||
}
|
||
},
|
||
})
|